mirror of
https://github.com/openai/codex.git
synced 2026-11-02 19:43:24 +00:00
Merge branch 'main' of https://github.com/openai/codex into dev/mzeng/new_elicitation
This commit is contained in:
Generated
-1
@@ -1987,7 +1987,6 @@ dependencies = [
|
||||
"sentry",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -953,25 +953,34 @@
|
||||
},
|
||||
"PluginInstallParams": {
|
||||
"properties": {
|
||||
"cwd": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"marketplaceName": {
|
||||
"type": "string"
|
||||
"marketplacePath": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
"pluginName": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"marketplaceName",
|
||||
"marketplacePath",
|
||||
"pluginName"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PluginListParams": {
|
||||
"properties": {
|
||||
"cwds": {
|
||||
"description": "Optional working directories used to discover repo marketplaces. When omitted, only home-scoped marketplaces are considered.",
|
||||
"items": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ProductSurface": {
|
||||
"enum": [
|
||||
"chatgpt",
|
||||
@@ -3264,6 +3273,30 @@
|
||||
"title": "Skills/listRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"plugin/list"
|
||||
],
|
||||
"title": "Plugin/listRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/PluginListParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Plugin/listRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
|
||||
+37
-39
@@ -31,38 +31,24 @@
|
||||
"AdditionalMacOsPermissions": {
|
||||
"properties": {
|
||||
"accessibility": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
"type": "boolean"
|
||||
},
|
||||
"automations": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/MacOsAutomationValue"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
"$ref": "#/definitions/MacOsAutomationPermission"
|
||||
},
|
||||
"calendar": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
"type": "boolean"
|
||||
},
|
||||
"preferences": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/MacOsPreferencesValue"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
"$ref": "#/definitions/MacOsPreferencesPermission"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"accessibility",
|
||||
"automations",
|
||||
"calendar",
|
||||
"preferences"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"AdditionalNetworkPermissions": {
|
||||
@@ -300,28 +286,40 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"MacOsAutomationValue": {
|
||||
"anyOf": [
|
||||
"MacOsAutomationPermission": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
"enum": [
|
||||
"none",
|
||||
"all"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"bundle_ids": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"type": "array"
|
||||
"required": [
|
||||
"bundle_ids"
|
||||
],
|
||||
"title": "BundleIdsMacOsAutomationPermission",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"MacOsPreferencesValue": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
"MacOsPreferencesPermission": {
|
||||
"enum": [
|
||||
"none",
|
||||
"read_only",
|
||||
"read_write"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"NetworkApprovalContext": {
|
||||
"properties": {
|
||||
|
||||
@@ -3765,66 +3765,70 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"MacOsAutomationValue": {
|
||||
"anyOf": [
|
||||
"MacOsAutomationPermission": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
"enum": [
|
||||
"none",
|
||||
"all"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"bundle_ids": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"type": "array"
|
||||
"required": [
|
||||
"bundle_ids"
|
||||
],
|
||||
"title": "BundleIdsMacOsAutomationPermission",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"MacOsPermissions": {
|
||||
"MacOsPreferencesPermission": {
|
||||
"enum": [
|
||||
"none",
|
||||
"read_only",
|
||||
"read_write"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"MacOsSeatbeltProfileExtensions": {
|
||||
"properties": {
|
||||
"accessibility": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
"macos_accessibility": {
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"automations": {
|
||||
"anyOf": [
|
||||
"macos_automation": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/MacOsAutomationValue"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
"$ref": "#/definitions/MacOsAutomationPermission"
|
||||
}
|
||||
]
|
||||
],
|
||||
"default": "none"
|
||||
},
|
||||
"calendar": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
"macos_calendar": {
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"preferences": {
|
||||
"anyOf": [
|
||||
"macos_preferences": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/MacOsPreferencesValue"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
"$ref": "#/definitions/MacOsPreferencesPermission"
|
||||
}
|
||||
]
|
||||
],
|
||||
"default": "read_only"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"MacOsPreferencesValue": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"McpAuthStatus": {
|
||||
"enum": [
|
||||
"unsupported",
|
||||
@@ -4168,7 +4172,7 @@
|
||||
"macos": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/MacOsPermissions"
|
||||
"$ref": "#/definitions/MacOsSeatbeltProfileExtensions"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
@@ -5614,9 +5618,6 @@
|
||||
},
|
||||
"SessionNetworkProxyRuntime": {
|
||||
"properties": {
|
||||
"admin_addr": {
|
||||
"type": "string"
|
||||
},
|
||||
"http_addr": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -5625,7 +5626,6 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"admin_addr",
|
||||
"http_addr",
|
||||
"socks_addr"
|
||||
],
|
||||
|
||||
@@ -201,6 +201,13 @@
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"pluginDisplayNames": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -31,38 +31,24 @@
|
||||
"AdditionalMacOsPermissions": {
|
||||
"properties": {
|
||||
"accessibility": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
"type": "boolean"
|
||||
},
|
||||
"automations": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/MacOsAutomationValue"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
"$ref": "#/definitions/MacOsAutomationPermission"
|
||||
},
|
||||
"calendar": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
"type": "boolean"
|
||||
},
|
||||
"preferences": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/MacOsPreferencesValue"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
"$ref": "#/definitions/MacOsPreferencesPermission"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"accessibility",
|
||||
"automations",
|
||||
"calendar",
|
||||
"preferences"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"AdditionalNetworkPermissions": {
|
||||
@@ -629,28 +615,40 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"MacOsAutomationValue": {
|
||||
"anyOf": [
|
||||
"MacOsAutomationPermission": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
"enum": [
|
||||
"none",
|
||||
"all"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"bundle_ids": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"type": "array"
|
||||
"required": [
|
||||
"bundle_ids"
|
||||
],
|
||||
"title": "BundleIdsMacOsAutomationPermission",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"MacOsPreferencesValue": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
"MacOsPreferencesPermission": {
|
||||
"enum": [
|
||||
"none",
|
||||
"read_only",
|
||||
"read_write"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"McpElicitationArrayType": {
|
||||
"enum": [
|
||||
|
||||
@@ -27,38 +27,24 @@
|
||||
"AdditionalMacOsPermissions": {
|
||||
"properties": {
|
||||
"accessibility": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
"type": "boolean"
|
||||
},
|
||||
"automations": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/MacOsAutomationValue"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
"$ref": "#/definitions/MacOsAutomationPermission"
|
||||
},
|
||||
"calendar": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
"type": "boolean"
|
||||
},
|
||||
"preferences": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/MacOsPreferencesValue"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
"$ref": "#/definitions/MacOsPreferencesPermission"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"accessibility",
|
||||
"automations",
|
||||
"calendar",
|
||||
"preferences"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"AdditionalNetworkPermissions": {
|
||||
@@ -730,6 +716,30 @@
|
||||
"title": "Skills/listRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/v2/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"plugin/list"
|
||||
],
|
||||
"title": "Plugin/listRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/v2/PluginListParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Plugin/listRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -5238,52 +5248,66 @@
|
||||
"title": "JSONRPCResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"MacOsAutomationValue": {
|
||||
"anyOf": [
|
||||
"MacOsAutomationPermission": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
"enum": [
|
||||
"none",
|
||||
"all"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"bundle_ids": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"type": "array"
|
||||
"required": [
|
||||
"bundle_ids"
|
||||
],
|
||||
"title": "BundleIdsMacOsAutomationPermission",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"MacOsPermissions": {
|
||||
"MacOsPreferencesPermission": {
|
||||
"enum": [
|
||||
"none",
|
||||
"read_only",
|
||||
"read_write"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"MacOsSeatbeltProfileExtensions": {
|
||||
"properties": {
|
||||
"accessibility": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
"macos_accessibility": {
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"automations": {
|
||||
"anyOf": [
|
||||
"macos_automation": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/MacOsAutomationValue"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
"$ref": "#/definitions/MacOsAutomationPermission"
|
||||
}
|
||||
]
|
||||
],
|
||||
"default": "none"
|
||||
},
|
||||
"calendar": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
"macos_calendar": {
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"preferences": {
|
||||
"anyOf": [
|
||||
"macos_preferences": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/MacOsPreferencesValue"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
"$ref": "#/definitions/MacOsPreferencesPermission"
|
||||
}
|
||||
]
|
||||
],
|
||||
"default": "read_only"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
@@ -6217,7 +6241,7 @@
|
||||
"macos": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/MacOsPermissions"
|
||||
"$ref": "#/definitions/MacOsSeatbeltProfileExtensions"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
@@ -7794,9 +7818,6 @@
|
||||
},
|
||||
"SessionNetworkProxyRuntime": {
|
||||
"properties": {
|
||||
"admin_addr": {
|
||||
"type": "string"
|
||||
},
|
||||
"http_addr": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -7805,7 +7826,6 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"admin_addr",
|
||||
"http_addr",
|
||||
"socks_addr"
|
||||
],
|
||||
@@ -8538,6 +8558,13 @@
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"pluginDisplayNames": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -8687,6 +8714,34 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"AppSummary": {
|
||||
"description": "EXPERIMENTAL - app metadata summary for plugin-install responses.",
|
||||
"properties": {
|
||||
"description": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"installUrl": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"AppToolApproval": {
|
||||
"enum": [
|
||||
"auto",
|
||||
@@ -11549,12 +11604,6 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"dangerouslyAllowNonLoopbackAdmin": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"dangerouslyAllowNonLoopbackProxy": {
|
||||
"type": [
|
||||
"boolean",
|
||||
@@ -11730,21 +11779,15 @@
|
||||
"PluginInstallParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"cwd": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"marketplaceName": {
|
||||
"type": "string"
|
||||
"marketplacePath": {
|
||||
"$ref": "#/definitions/v2/AbsolutePathBuf"
|
||||
},
|
||||
"pluginName": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"marketplaceName",
|
||||
"marketplacePath",
|
||||
"pluginName"
|
||||
],
|
||||
"title": "PluginInstallParams",
|
||||
@@ -11752,9 +11795,118 @@
|
||||
},
|
||||
"PluginInstallResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"appsNeedingAuth": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/v2/AppSummary"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"appsNeedingAuth"
|
||||
],
|
||||
"title": "PluginInstallResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"PluginListParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"cwds": {
|
||||
"description": "Optional working directories used to discover repo marketplaces. When omitted, only home-scoped marketplaces are considered.",
|
||||
"items": {
|
||||
"$ref": "#/definitions/v2/AbsolutePathBuf"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"title": "PluginListParams",
|
||||
"type": "object"
|
||||
},
|
||||
"PluginListResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"marketplaces": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/v2/PluginMarketplaceEntry"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"marketplaces"
|
||||
],
|
||||
"title": "PluginListResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"PluginMarketplaceEntry": {
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
},
|
||||
"plugins": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/v2/PluginSummary"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"path",
|
||||
"plugins"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PluginSource": {
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"local"
|
||||
],
|
||||
"title": "LocalPluginSourceType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"path",
|
||||
"type"
|
||||
],
|
||||
"title": "LocalPluginSource",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"PluginSummary": {
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"source": {
|
||||
"$ref": "#/definitions/v2/PluginSource"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"enabled",
|
||||
"name",
|
||||
"source"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ProductSurface": {
|
||||
"enum": [
|
||||
"chatgpt",
|
||||
|
||||
+217
-61
@@ -404,6 +404,13 @@
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"pluginDisplayNames": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -553,6 +560,34 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"AppSummary": {
|
||||
"description": "EXPERIMENTAL - app metadata summary for plugin-install responses.",
|
||||
"properties": {
|
||||
"description": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"installUrl": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"AppToolApproval": {
|
||||
"enum": [
|
||||
"auto",
|
||||
@@ -1204,6 +1239,30 @@
|
||||
"title": "Skills/listRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"plugin/list"
|
||||
],
|
||||
"title": "Plugin/listRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/PluginListParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Plugin/listRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -7352,66 +7411,70 @@
|
||||
"title": "LogoutAccountResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"MacOsAutomationValue": {
|
||||
"anyOf": [
|
||||
"MacOsAutomationPermission": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
"enum": [
|
||||
"none",
|
||||
"all"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"bundle_ids": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"type": "array"
|
||||
"required": [
|
||||
"bundle_ids"
|
||||
],
|
||||
"title": "BundleIdsMacOsAutomationPermission",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"MacOsPermissions": {
|
||||
"MacOsPreferencesPermission": {
|
||||
"enum": [
|
||||
"none",
|
||||
"read_only",
|
||||
"read_write"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"MacOsSeatbeltProfileExtensions": {
|
||||
"properties": {
|
||||
"accessibility": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
"macos_accessibility": {
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"automations": {
|
||||
"anyOf": [
|
||||
"macos_automation": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/MacOsAutomationValue"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
"$ref": "#/definitions/MacOsAutomationPermission"
|
||||
}
|
||||
]
|
||||
],
|
||||
"default": "none"
|
||||
},
|
||||
"calendar": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
"macos_calendar": {
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"preferences": {
|
||||
"anyOf": [
|
||||
"macos_preferences": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/MacOsPreferencesValue"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
"$ref": "#/definitions/MacOsPreferencesPermission"
|
||||
}
|
||||
]
|
||||
],
|
||||
"default": "read_only"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"MacOsPreferencesValue": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"McpAuthStatus": {
|
||||
"enum": [
|
||||
"unsupported",
|
||||
@@ -8032,12 +8095,6 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"dangerouslyAllowNonLoopbackAdmin": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"dangerouslyAllowNonLoopbackProxy": {
|
||||
"type": [
|
||||
"boolean",
|
||||
@@ -8288,7 +8345,7 @@
|
||||
"macos": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/MacOsPermissions"
|
||||
"$ref": "#/definitions/MacOsSeatbeltProfileExtensions"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
@@ -8375,21 +8432,15 @@
|
||||
"PluginInstallParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"cwd": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"marketplaceName": {
|
||||
"type": "string"
|
||||
"marketplacePath": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
"pluginName": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"marketplaceName",
|
||||
"marketplacePath",
|
||||
"pluginName"
|
||||
],
|
||||
"title": "PluginInstallParams",
|
||||
@@ -8397,9 +8448,118 @@
|
||||
},
|
||||
"PluginInstallResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"appsNeedingAuth": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/AppSummary"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"appsNeedingAuth"
|
||||
],
|
||||
"title": "PluginInstallResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"PluginListParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"cwds": {
|
||||
"description": "Optional working directories used to discover repo marketplaces. When omitted, only home-scoped marketplaces are considered.",
|
||||
"items": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"title": "PluginListParams",
|
||||
"type": "object"
|
||||
},
|
||||
"PluginListResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"marketplaces": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/PluginMarketplaceEntry"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"marketplaces"
|
||||
],
|
||||
"title": "PluginListResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"PluginMarketplaceEntry": {
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
},
|
||||
"plugins": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/PluginSummary"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"path",
|
||||
"plugins"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PluginSource": {
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"local"
|
||||
],
|
||||
"title": "LocalPluginSourceType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"path",
|
||||
"type"
|
||||
],
|
||||
"title": "LocalPluginSource",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"PluginSummary": {
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"source": {
|
||||
"$ref": "#/definitions/PluginSource"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"enabled",
|
||||
"name",
|
||||
"source"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ProductSurface": {
|
||||
"enum": [
|
||||
"chatgpt",
|
||||
@@ -10976,9 +11136,6 @@
|
||||
},
|
||||
"SessionNetworkProxyRuntime": {
|
||||
"properties": {
|
||||
"admin_addr": {
|
||||
"type": "string"
|
||||
},
|
||||
"http_addr": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -10987,7 +11144,6 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"admin_addr",
|
||||
"http_addr",
|
||||
"socks_addr"
|
||||
],
|
||||
|
||||
@@ -119,6 +119,13 @@
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"pluginDisplayNames": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -119,6 +119,13 @@
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"pluginDisplayNames": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -132,12 +132,6 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"dangerouslyAllowNonLoopbackAdmin": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"dangerouslyAllowNonLoopbackProxy": {
|
||||
"type": [
|
||||
"boolean",
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"cwd": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"marketplaceName": {
|
||||
"definitions": {
|
||||
"AbsolutePathBuf": {
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"marketplacePath": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
"pluginName": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"marketplaceName",
|
||||
"marketplacePath",
|
||||
"pluginName"
|
||||
],
|
||||
"title": "PluginInstallParams",
|
||||
|
||||
@@ -1,5 +1,46 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"AppSummary": {
|
||||
"description": "EXPERIMENTAL - app metadata summary for plugin-install responses.",
|
||||
"properties": {
|
||||
"description": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"installUrl": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"appsNeedingAuth": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/AppSummary"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"appsNeedingAuth"
|
||||
],
|
||||
"title": "PluginInstallResponse",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"AbsolutePathBuf": {
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"cwds": {
|
||||
"description": "Optional working directories used to discover repo marketplaces. When omitted, only home-scoped marketplaces are considered.",
|
||||
"items": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"title": "PluginListParams",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"PluginMarketplaceEntry": {
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
},
|
||||
"plugins": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/PluginSummary"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"path",
|
||||
"plugins"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PluginSource": {
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"local"
|
||||
],
|
||||
"title": "LocalPluginSourceType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"path",
|
||||
"type"
|
||||
],
|
||||
"title": "LocalPluginSource",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"PluginSummary": {
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"source": {
|
||||
"$ref": "#/definitions/PluginSource"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"enabled",
|
||||
"name",
|
||||
"source"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"marketplaces": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/PluginMarketplaceEntry"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"marketplaces"
|
||||
],
|
||||
"title": "PluginListResponse",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import type { LoginAccountParams } from "./v2/LoginAccountParams";
|
||||
import type { McpServerOauthLoginParams } from "./v2/McpServerOauthLoginParams";
|
||||
import type { ModelListParams } from "./v2/ModelListParams";
|
||||
import type { PluginInstallParams } from "./v2/PluginInstallParams";
|
||||
import type { PluginListParams } from "./v2/PluginListParams";
|
||||
import type { ReviewStartParams } from "./v2/ReviewStartParams";
|
||||
import type { SkillsConfigWriteParams } from "./v2/SkillsConfigWriteParams";
|
||||
import type { SkillsListParams } from "./v2/SkillsListParams";
|
||||
@@ -49,4 +50,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta
|
||||
/**
|
||||
* Request from the client to the server.
|
||||
*/
|
||||
export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "skills/remote/list", id: RequestId, params: SkillsRemoteReadParams, } | { "method": "skills/remote/export", id: RequestId, params: SkillsRemoteWriteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, };
|
||||
export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "skills/remote/list", id: RequestId, params: SkillsRemoteReadParams, } | { "method": "skills/remote/export", id: RequestId, params: SkillsRemoteWriteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, };
|
||||
|
||||
@@ -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 MacOsAutomationPermission = "none" | "all" | { "bundle_ids": Array<string> };
|
||||
@@ -1,7 +0,0 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { MacOsAutomationValue } from "./MacOsAutomationValue";
|
||||
import type { MacOsPreferencesValue } from "./MacOsPreferencesValue";
|
||||
|
||||
export type MacOsPermissions = { preferences: MacOsPreferencesValue | null, automations: MacOsAutomationValue | null, accessibility: boolean | null, calendar: boolean | null, };
|
||||
+1
-1
@@ -2,4 +2,4 @@
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type MacOsPreferencesValue = boolean | string;
|
||||
export type MacOsPreferencesPermission = "none" | "read_only" | "read_write";
|
||||
@@ -0,0 +1,7 @@
|
||||
// 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 { MacOsAutomationPermission } from "./MacOsAutomationPermission";
|
||||
import type { MacOsPreferencesPermission } from "./MacOsPreferencesPermission";
|
||||
|
||||
export type MacOsSeatbeltProfileExtensions = { macos_preferences: MacOsPreferencesPermission, macos_automation: MacOsAutomationPermission, macos_accessibility: boolean, macos_calendar: boolean, };
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { FileSystemPermissions } from "./FileSystemPermissions";
|
||||
import type { MacOsPermissions } from "./MacOsPermissions";
|
||||
import type { MacOsSeatbeltProfileExtensions } from "./MacOsSeatbeltProfileExtensions";
|
||||
import type { NetworkPermissions } from "./NetworkPermissions";
|
||||
|
||||
export type PermissionProfile = { network: NetworkPermissions | null, file_system: FileSystemPermissions | null, macos: MacOsPermissions | null, };
|
||||
export type PermissionProfile = { network: NetworkPermissions | null, file_system: FileSystemPermissions | null, macos: MacOsSeatbeltProfileExtensions | null, };
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type SessionNetworkProxyRuntime = { http_addr: string, socks_addr: string, admin_addr: string, };
|
||||
export type SessionNetworkProxyRuntime = { http_addr: string, socks_addr: string, };
|
||||
|
||||
@@ -100,9 +100,9 @@ export type { ListSkillsResponseEvent } from "./ListSkillsResponseEvent";
|
||||
export type { LocalShellAction } from "./LocalShellAction";
|
||||
export type { LocalShellExecAction } from "./LocalShellExecAction";
|
||||
export type { LocalShellStatus } from "./LocalShellStatus";
|
||||
export type { MacOsAutomationValue } from "./MacOsAutomationValue";
|
||||
export type { MacOsPermissions } from "./MacOsPermissions";
|
||||
export type { MacOsPreferencesValue } from "./MacOsPreferencesValue";
|
||||
export type { MacOsAutomationPermission } from "./MacOsAutomationPermission";
|
||||
export type { MacOsPreferencesPermission } from "./MacOsPreferencesPermission";
|
||||
export type { MacOsSeatbeltProfileExtensions } from "./MacOsSeatbeltProfileExtensions";
|
||||
export type { McpAuthStatus } from "./McpAuthStatus";
|
||||
export type { McpInvocation } from "./McpInvocation";
|
||||
export type { McpListToolsResponseEvent } from "./McpListToolsResponseEvent";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// 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 { MacOsAutomationValue } from "../MacOsAutomationValue";
|
||||
import type { MacOsPreferencesValue } from "../MacOsPreferencesValue";
|
||||
import type { MacOsAutomationPermission } from "../MacOsAutomationPermission";
|
||||
import type { MacOsPreferencesPermission } from "../MacOsPreferencesPermission";
|
||||
|
||||
export type AdditionalMacOsPermissions = { preferences: MacOsPreferencesValue | null, automations: MacOsAutomationValue | null, accessibility: boolean | null, calendar: boolean | null, };
|
||||
export type AdditionalMacOsPermissions = { preferences: MacOsPreferencesPermission, automations: MacOsAutomationPermission, accessibility: boolean, calendar: boolean, };
|
||||
|
||||
@@ -16,4 +16,4 @@ export type AppInfo = { id: string, name: string, description: string | null, lo
|
||||
* enabled = false
|
||||
* ```
|
||||
*/
|
||||
isEnabled: boolean, };
|
||||
isEnabled: boolean, pluginDisplayNames: Array<string>, };
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// 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.
|
||||
|
||||
/**
|
||||
* EXPERIMENTAL - app metadata summary for plugin-install responses.
|
||||
*/
|
||||
export type AppSummary = { id: string, name: string, description: string | null, installUrl: string | null, };
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type NetworkRequirements = { enabled: boolean | null, httpPort: number | null, socksPort: number | null, allowUpstreamProxy: boolean | null, dangerouslyAllowNonLoopbackProxy: boolean | null, dangerouslyAllowNonLoopbackAdmin: boolean | null, dangerouslyAllowAllUnixSockets: boolean | null, allowedDomains: Array<string> | null, deniedDomains: Array<string> | null, allowUnixSockets: Array<string> | null, allowLocalBinding: boolean | null, };
|
||||
export type NetworkRequirements = { enabled: boolean | null, httpPort: number | null, socksPort: number | null, allowUpstreamProxy: boolean | null, dangerouslyAllowNonLoopbackProxy: boolean | null, dangerouslyAllowAllUnixSockets: boolean | null, allowedDomains: Array<string> | null, deniedDomains: Array<string> | null, allowUnixSockets: Array<string> | null, allowLocalBinding: boolean | null, };
|
||||
|
||||
@@ -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 { AbsolutePathBuf } from "../AbsolutePathBuf";
|
||||
|
||||
export type PluginInstallParams = { marketplaceName: string, pluginName: string, cwd?: string | null, };
|
||||
export type PluginInstallParams = { marketplacePath: AbsolutePathBuf, pluginName: string, };
|
||||
|
||||
@@ -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 { AppSummary } from "./AppSummary";
|
||||
|
||||
export type PluginInstallResponse = Record<string, never>;
|
||||
export type PluginInstallResponse = { appsNeedingAuth: Array<AppSummary>, };
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
|
||||
|
||||
export type PluginListParams = {
|
||||
/**
|
||||
* Optional working directories used to discover repo marketplaces. When omitted,
|
||||
* only home-scoped marketplaces are considered.
|
||||
*/
|
||||
cwds?: Array<AbsolutePathBuf> | null, };
|
||||
@@ -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 { PluginMarketplaceEntry } from "./PluginMarketplaceEntry";
|
||||
|
||||
export type PluginListResponse = { marketplaces: Array<PluginMarketplaceEntry>, };
|
||||
@@ -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 { PluginSummary } from "./PluginSummary";
|
||||
|
||||
export type PluginMarketplaceEntry = { name: string, path: string, plugins: Array<PluginSummary>, };
|
||||
+1
-1
@@ -2,4 +2,4 @@
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type MacOsAutomationValue = boolean | Array<string>;
|
||||
export type PluginSource = { "type": "local", path: 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 { PluginSource } from "./PluginSource";
|
||||
|
||||
export type PluginSummary = { name: string, source: PluginSource, enabled: boolean, };
|
||||
@@ -16,6 +16,7 @@ export type { AppListUpdatedNotification } from "./AppListUpdatedNotification";
|
||||
export type { AppMetadata } from "./AppMetadata";
|
||||
export type { AppReview } from "./AppReview";
|
||||
export type { AppScreenshot } from "./AppScreenshot";
|
||||
export type { AppSummary } from "./AppSummary";
|
||||
export type { AppToolApproval } from "./AppToolApproval";
|
||||
export type { AppToolsConfig } from "./AppToolsConfig";
|
||||
export type { AppsConfig } from "./AppsConfig";
|
||||
@@ -151,6 +152,11 @@ export type { PatchChangeKind } from "./PatchChangeKind";
|
||||
export type { PlanDeltaNotification } from "./PlanDeltaNotification";
|
||||
export type { PluginInstallParams } from "./PluginInstallParams";
|
||||
export type { PluginInstallResponse } from "./PluginInstallResponse";
|
||||
export type { PluginListParams } from "./PluginListParams";
|
||||
export type { PluginListResponse } from "./PluginListResponse";
|
||||
export type { PluginMarketplaceEntry } from "./PluginMarketplaceEntry";
|
||||
export type { PluginSource } from "./PluginSource";
|
||||
export type { PluginSummary } from "./PluginSummary";
|
||||
export type { ProductSurface } from "./ProductSurface";
|
||||
export type { ProfileV2 } from "./ProfileV2";
|
||||
export type { RateLimitSnapshot } from "./RateLimitSnapshot";
|
||||
|
||||
@@ -248,6 +248,10 @@ client_request_definitions! {
|
||||
params: v2::SkillsListParams,
|
||||
response: v2::SkillsListResponse,
|
||||
},
|
||||
PluginList => "plugin/list" {
|
||||
params: v2::PluginListParams,
|
||||
response: v2::PluginListResponse,
|
||||
},
|
||||
SkillsRemoteList => "skills/remote/list" {
|
||||
params: v2::SkillsRemoteReadParams,
|
||||
response: v2::SkillsRemoteReadResponse,
|
||||
|
||||
@@ -28,9 +28,9 @@ use codex_protocol::mcp::Resource as McpResource;
|
||||
use codex_protocol::mcp::ResourceTemplate as McpResourceTemplate;
|
||||
use codex_protocol::mcp::Tool as McpTool;
|
||||
use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions;
|
||||
use codex_protocol::models::MacOsAutomationValue as CoreMacOsAutomationValue;
|
||||
use codex_protocol::models::MacOsPermissions as CoreMacOsPermissions;
|
||||
use codex_protocol::models::MacOsPreferencesValue as CoreMacOsPreferencesValue;
|
||||
use codex_protocol::models::MacOsAutomationPermission as CoreMacOsAutomationPermission;
|
||||
use codex_protocol::models::MacOsPreferencesPermission as CoreMacOsPreferencesPermission;
|
||||
use codex_protocol::models::MacOsSeatbeltProfileExtensions as CoreMacOsSeatbeltProfileExtensions;
|
||||
use codex_protocol::models::MessagePhase;
|
||||
use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions;
|
||||
use codex_protocol::models::PermissionProfile as CorePermissionProfile;
|
||||
@@ -629,7 +629,6 @@ pub struct NetworkRequirements {
|
||||
pub socks_port: Option<u16>,
|
||||
pub allow_upstream_proxy: Option<bool>,
|
||||
pub dangerously_allow_non_loopback_proxy: Option<bool>,
|
||||
pub dangerously_allow_non_loopback_admin: Option<bool>,
|
||||
pub dangerously_allow_all_unix_sockets: Option<bool>,
|
||||
pub allowed_domains: Option<Vec<String>>,
|
||||
pub denied_domains: Option<Vec<String>>,
|
||||
@@ -837,19 +836,19 @@ impl From<CoreFileSystemPermissions> for AdditionalFileSystemPermissions {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct AdditionalMacOsPermissions {
|
||||
pub preferences: Option<CoreMacOsPreferencesValue>,
|
||||
pub automations: Option<CoreMacOsAutomationValue>,
|
||||
pub accessibility: Option<bool>,
|
||||
pub calendar: Option<bool>,
|
||||
pub preferences: CoreMacOsPreferencesPermission,
|
||||
pub automations: CoreMacOsAutomationPermission,
|
||||
pub accessibility: bool,
|
||||
pub calendar: bool,
|
||||
}
|
||||
|
||||
impl From<CoreMacOsPermissions> for AdditionalMacOsPermissions {
|
||||
fn from(value: CoreMacOsPermissions) -> Self {
|
||||
impl From<CoreMacOsSeatbeltProfileExtensions> for AdditionalMacOsPermissions {
|
||||
fn from(value: CoreMacOsSeatbeltProfileExtensions) -> Self {
|
||||
Self {
|
||||
preferences: value.preferences,
|
||||
automations: value.automations,
|
||||
accessibility: value.accessibility,
|
||||
calendar: value.calendar,
|
||||
preferences: value.macos_preferences,
|
||||
automations: value.macos_automation,
|
||||
accessibility: value.macos_accessibility,
|
||||
calendar: value.macos_calendar,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1709,6 +1708,30 @@ pub struct AppInfo {
|
||||
/// ```
|
||||
#[serde(default = "default_enabled")]
|
||||
pub is_enabled: bool,
|
||||
#[serde(default)]
|
||||
pub plugin_display_names: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
/// EXPERIMENTAL - app metadata summary for plugin-install responses.
|
||||
pub struct AppSummary {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub install_url: Option<String>,
|
||||
}
|
||||
|
||||
impl From<AppInfo> for AppSummary {
|
||||
fn from(value: AppInfo) -> Self {
|
||||
Self {
|
||||
id: value.id,
|
||||
name: value.name,
|
||||
description: value.description,
|
||||
install_url: value.install_url,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
@@ -2369,6 +2392,23 @@ pub struct SkillsListResponse {
|
||||
pub data: Vec<SkillsListEntry>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct PluginListParams {
|
||||
/// Optional working directories used to discover repo marketplaces. When omitted,
|
||||
/// only home-scoped marketplaces are considered.
|
||||
#[ts(optional = nullable)]
|
||||
pub cwds: Option<Vec<AbsolutePathBuf>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct PluginListResponse {
|
||||
pub marketplaces: Vec<PluginMarketplaceEntry>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
@@ -2532,6 +2572,34 @@ pub struct SkillsListEntry {
|
||||
pub errors: Vec<SkillErrorInfo>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct PluginMarketplaceEntry {
|
||||
pub name: String,
|
||||
pub path: PathBuf,
|
||||
pub plugins: Vec<PluginSummary>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct PluginSummary {
|
||||
pub name: String,
|
||||
pub source: PluginSource,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
#[ts(tag = "type")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub enum PluginSource {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
Local { path: PathBuf },
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
@@ -2551,16 +2619,16 @@ pub struct SkillsConfigWriteResponse {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct PluginInstallParams {
|
||||
pub marketplace_name: String,
|
||||
pub marketplace_path: AbsolutePathBuf,
|
||||
pub plugin_name: String,
|
||||
#[ts(optional = nullable)]
|
||||
pub cwd: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct PluginInstallResponse {}
|
||||
pub struct PluginInstallResponse {
|
||||
pub apps_needing_auth: Vec<AppSummary>,
|
||||
}
|
||||
|
||||
impl From<CoreSkillMetadata> for SkillMetadata {
|
||||
fn from(value: CoreSkillMetadata) -> Self {
|
||||
@@ -4839,6 +4907,46 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_execution_request_approval_accepts_macos_automation_bundle_ids_object() {
|
||||
let params = serde_json::from_value::<CommandExecutionRequestApprovalParams>(json!({
|
||||
"threadId": "thr_123",
|
||||
"turnId": "turn_123",
|
||||
"itemId": "call_123",
|
||||
"command": "cat file",
|
||||
"cwd": "/tmp",
|
||||
"commandActions": null,
|
||||
"reason": null,
|
||||
"networkApprovalContext": null,
|
||||
"additionalPermissions": {
|
||||
"network": null,
|
||||
"fileSystem": null,
|
||||
"macos": {
|
||||
"preferences": "read_only",
|
||||
"automations": {
|
||||
"bundle_ids": ["com.apple.Notes"]
|
||||
},
|
||||
"accessibility": false,
|
||||
"calendar": false
|
||||
}
|
||||
},
|
||||
"proposedExecpolicyAmendment": null,
|
||||
"proposedNetworkPolicyAmendments": null,
|
||||
"availableDecisions": null
|
||||
}))
|
||||
.expect("bundle_ids object should deserialize");
|
||||
|
||||
assert_eq!(
|
||||
params
|
||||
.additional_permissions
|
||||
.and_then(|permissions| permissions.macos)
|
||||
.map(|macos| macos.automations),
|
||||
Some(CoreMacOsAutomationPermission::BundleIds(vec![
|
||||
"com.apple.Notes".to_string(),
|
||||
]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_policy_round_trips_external_sandbox_network_access() {
|
||||
let v2_policy = SandboxPolicy::ExternalSandbox {
|
||||
|
||||
@@ -148,12 +148,13 @@ Example with notification opt-out:
|
||||
- `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. For non-beta flags, `displayName`/`description`/`announcement` are `null`.
|
||||
- `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). 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`).
|
||||
- `plugin/list` — list discovered marketplaces reachable from optional `cwds` (unioned into a single list). When `cwds` is omitted, only home-scoped marketplaces are considered. Includes each plugin's current `enabled` state from config (**under development; do not call from production clients yet**).
|
||||
- `skills/changed` — notification emitted when watched local skill files change.
|
||||
- `skills/remote/list` — list public remote skills (**under development; do not call from production clients yet**).
|
||||
- `skills/remote/export` — download a remote skill by `hazelnutId` into `skills` under `codex_home` (**under development; do not call from production clients yet**).
|
||||
- `app/list` — list available apps.
|
||||
- `skills/config/write` — write user-level skill config by path.
|
||||
- `plugin/install` — install a plugin from a discovered marketplace entry by `pluginName` and `marketplaceName` (**under development; do not call from production clients yet**).
|
||||
- `plugin/install` — install a plugin from a discovered marketplace entry by `pluginName` and `marketplacePath`; on success it returns `appsNeedingAuth` for any plugin-declared apps that still are not accessible in the current ChatGPT auth context (**under development; do not call from production clients yet**).
|
||||
- `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes.
|
||||
- `tool/requestUserInput` — prompt the user with 1–3 short questions for a tool call and return their answers (experimental).
|
||||
- `config/mcpServer/reload` — reload MCP server config from disk and queue a refresh for loaded threads (applied on each thread's next active turn); returns `{}`. Use this after editing `config.toml` without restarting the server.
|
||||
|
||||
@@ -22,6 +22,7 @@ use codex_app_server_protocol::AccountLoginCompletedNotification;
|
||||
use codex_app_server_protocol::AccountUpdatedNotification;
|
||||
use codex_app_server_protocol::AppInfo;
|
||||
use codex_app_server_protocol::AppListUpdatedNotification;
|
||||
use codex_app_server_protocol::AppSummary;
|
||||
use codex_app_server_protocol::AppsListParams;
|
||||
use codex_app_server_protocol::AppsListResponse;
|
||||
use codex_app_server_protocol::AskForApproval;
|
||||
@@ -79,6 +80,11 @@ use codex_app_server_protocol::ModelListParams;
|
||||
use codex_app_server_protocol::ModelListResponse;
|
||||
use codex_app_server_protocol::PluginInstallParams;
|
||||
use codex_app_server_protocol::PluginInstallResponse;
|
||||
use codex_app_server_protocol::PluginListParams;
|
||||
use codex_app_server_protocol::PluginListResponse;
|
||||
use codex_app_server_protocol::PluginMarketplaceEntry;
|
||||
use codex_app_server_protocol::PluginSource;
|
||||
use codex_app_server_protocol::PluginSummary;
|
||||
use codex_app_server_protocol::ProductSurface as ApiProductSurface;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ReviewDelivery as ApiReviewDelivery;
|
||||
@@ -182,6 +188,8 @@ use codex_core::config::edit::ConfigEdit;
|
||||
use codex_core::config::edit::ConfigEditsBuilder;
|
||||
use codex_core::config::types::McpServerTransportConfig;
|
||||
use codex_core::config_loader::CloudRequirementsLoader;
|
||||
use codex_core::connectors::filter_disallowed_connectors;
|
||||
use codex_core::connectors::merge_plugin_apps;
|
||||
use codex_core::default_client::set_default_client_residency_requirement;
|
||||
use codex_core::error::CodexErr;
|
||||
use codex_core::exec::ExecParams;
|
||||
@@ -198,8 +206,12 @@ use codex_core::mcp::collect_mcp_snapshot;
|
||||
use codex_core::mcp::group_tools_by_server;
|
||||
use codex_core::models_manager::collaboration_mode_presets::CollaborationModesConfig;
|
||||
use codex_core::parse_cursor;
|
||||
use codex_core::plugins::AppConnectorId;
|
||||
use codex_core::plugins::MarketplaceError;
|
||||
use codex_core::plugins::MarketplacePluginSourceSummary;
|
||||
use codex_core::plugins::PluginInstallError as CorePluginInstallError;
|
||||
use codex_core::plugins::PluginInstallRequest;
|
||||
use codex_core::plugins::load_plugin_apps;
|
||||
use codex_core::read_head_for_summary;
|
||||
use codex_core::read_session_meta_line;
|
||||
use codex_core::rollout_date_parts;
|
||||
@@ -461,10 +473,14 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_latest_config(&self) -> Result<Config, JSONRPCErrorError> {
|
||||
async fn load_latest_config(
|
||||
&self,
|
||||
fallback_cwd: Option<PathBuf>,
|
||||
) -> Result<Config, JSONRPCErrorError> {
|
||||
let cloud_requirements = self.current_cloud_requirements();
|
||||
let mut config = codex_core::config::ConfigBuilder::default()
|
||||
.cli_overrides(self.cli_overrides.clone())
|
||||
.fallback_cwd(fallback_cwd)
|
||||
.cloud_requirements(cloud_requirements)
|
||||
.build()
|
||||
.await
|
||||
@@ -646,6 +662,10 @@ impl CodexMessageProcessor {
|
||||
self.skills_list(to_connection_request_id(request_id), params)
|
||||
.await;
|
||||
}
|
||||
ClientRequest::PluginList { request_id, params } => {
|
||||
self.plugin_list(to_connection_request_id(request_id), params)
|
||||
.await;
|
||||
}
|
||||
ClientRequest::SkillsRemoteList { request_id, params } => {
|
||||
self.skills_remote_list(to_connection_request_id(request_id), params)
|
||||
.await;
|
||||
@@ -3902,7 +3922,7 @@ impl CodexMessageProcessor {
|
||||
params: ExperimentalFeatureListParams,
|
||||
) {
|
||||
let ExperimentalFeatureListParams { cursor, limit } = params;
|
||||
let config = match self.load_latest_config().await {
|
||||
let config = match self.load_latest_config(None).await {
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
@@ -4017,7 +4037,7 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
|
||||
async fn mcp_server_refresh(&self, request_id: ConnectionRequestId, _params: Option<()>) {
|
||||
let config = match self.load_latest_config().await {
|
||||
let config = match self.load_latest_config(None).await {
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
@@ -4076,7 +4096,7 @@ impl CodexMessageProcessor {
|
||||
request_id: ConnectionRequestId,
|
||||
params: McpServerOauthLoginParams,
|
||||
) {
|
||||
let config = match self.load_latest_config().await {
|
||||
let config = match self.load_latest_config(None).await {
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
@@ -4182,7 +4202,7 @@ impl CodexMessageProcessor {
|
||||
let request = request_id.clone();
|
||||
|
||||
let outgoing = Arc::clone(&self.outgoing);
|
||||
let config = match self.load_latest_config().await {
|
||||
let config = match self.load_latest_config(None).await {
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
self.outgoing.send_error(request, error).await;
|
||||
@@ -4318,6 +4338,30 @@ impl CodexMessageProcessor {
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
}
|
||||
|
||||
async fn send_marketplace_error(
|
||||
&self,
|
||||
request_id: ConnectionRequestId,
|
||||
err: MarketplaceError,
|
||||
action: &str,
|
||||
) {
|
||||
match err {
|
||||
MarketplaceError::MarketplaceNotFound { .. } => {
|
||||
self.send_invalid_request_error(request_id, err.to_string())
|
||||
.await;
|
||||
}
|
||||
MarketplaceError::Io { .. } => {
|
||||
self.send_internal_error(request_id, format!("failed to {action}: {err}"))
|
||||
.await;
|
||||
}
|
||||
MarketplaceError::InvalidMarketplaceFile { .. }
|
||||
| MarketplaceError::PluginNotFound { .. }
|
||||
| MarketplaceError::InvalidPlugin(_) => {
|
||||
self.send_invalid_request_error(request_id, err.to_string())
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_thread_shutdown(thread: &Arc<CodexThread>) -> ThreadShutdownResult {
|
||||
match thread.submit(Op::Shutdown).await {
|
||||
Ok(_) => {
|
||||
@@ -4581,7 +4625,7 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
|
||||
async fn apps_list(&self, request_id: ConnectionRequestId, params: AppsListParams) {
|
||||
let mut config = match self.load_latest_config().await {
|
||||
let mut config = match self.load_latest_config(None).await {
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
@@ -4812,6 +4856,36 @@ impl CodexMessageProcessor {
|
||||
connectors::merge_connectors_with_accessible(all, accessible, all_connectors_loaded)
|
||||
}
|
||||
|
||||
fn plugin_apps_needing_auth(
|
||||
all_connectors: &[AppInfo],
|
||||
accessible_connectors: &[AppInfo],
|
||||
plugin_apps: &[AppConnectorId],
|
||||
codex_apps_ready: bool,
|
||||
) -> Vec<AppSummary> {
|
||||
if !codex_apps_ready {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let accessible_ids = accessible_connectors
|
||||
.iter()
|
||||
.map(|connector| connector.id.as_str())
|
||||
.collect::<HashSet<_>>();
|
||||
let plugin_app_ids = plugin_apps
|
||||
.iter()
|
||||
.map(|connector_id| connector_id.0.as_str())
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
all_connectors
|
||||
.iter()
|
||||
.filter(|connector| {
|
||||
plugin_app_ids.contains(connector.id.as_str())
|
||||
&& !accessible_ids.contains(connector.id.as_str())
|
||||
})
|
||||
.cloned()
|
||||
.map(AppSummary::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn should_send_app_list_updated_notification(
|
||||
connectors: &[AppInfo],
|
||||
accessible_loaded: bool,
|
||||
@@ -4924,6 +4998,66 @@ impl CodexMessageProcessor {
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn plugin_list(&self, request_id: ConnectionRequestId, params: PluginListParams) {
|
||||
let plugins_manager = self.thread_manager.plugins_manager();
|
||||
let roots = params.cwds.unwrap_or_default();
|
||||
|
||||
let config = match self.load_latest_config(None).await {
|
||||
Ok(config) => config,
|
||||
Err(err) => {
|
||||
self.outgoing.send_error(request_id, err).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let data = match tokio::task::spawn_blocking(move || {
|
||||
let marketplaces = plugins_manager.list_marketplaces_for_config(&config, &roots)?;
|
||||
Ok::<Vec<PluginMarketplaceEntry>, MarketplaceError>(
|
||||
marketplaces
|
||||
.into_iter()
|
||||
.map(|marketplace| PluginMarketplaceEntry {
|
||||
name: marketplace.name,
|
||||
path: marketplace.path,
|
||||
plugins: marketplace
|
||||
.plugins
|
||||
.into_iter()
|
||||
.map(|plugin| PluginSummary {
|
||||
enabled: plugin.enabled,
|
||||
name: plugin.name,
|
||||
source: match plugin.source {
|
||||
MarketplacePluginSourceSummary::Local { path } => {
|
||||
PluginSource::Local { path }
|
||||
}
|
||||
},
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Ok(data)) => data,
|
||||
Ok(Err(err)) => {
|
||||
self.send_marketplace_error(request_id, err, "list marketplace plugins")
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
Err(err) => {
|
||||
self.send_internal_error(
|
||||
request_id,
|
||||
format!("failed to list marketplace plugins: {err}"),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
self.outgoing
|
||||
.send_response(request_id, PluginListResponse { marketplaces: data })
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn skills_remote_list(
|
||||
&self,
|
||||
request_id: ConnectionRequestId,
|
||||
@@ -5034,24 +5168,96 @@ impl CodexMessageProcessor {
|
||||
|
||||
async fn plugin_install(&self, request_id: ConnectionRequestId, params: PluginInstallParams) {
|
||||
let PluginInstallParams {
|
||||
marketplace_name,
|
||||
marketplace_path,
|
||||
plugin_name,
|
||||
cwd,
|
||||
} = params;
|
||||
let config_cwd = marketplace_path.as_path().parent().map(Path::to_path_buf);
|
||||
|
||||
let plugins_manager = self.thread_manager.plugins_manager();
|
||||
let request = PluginInstallRequest {
|
||||
plugin_name,
|
||||
marketplace_name,
|
||||
cwd: cwd.unwrap_or_else(|| self.config.cwd.clone()),
|
||||
marketplace_path,
|
||||
};
|
||||
|
||||
match plugins_manager.install_plugin(request).await {
|
||||
Ok(_) => {
|
||||
Ok(result) => {
|
||||
let config = match self.load_latest_config(config_cwd).await {
|
||||
Ok(config) => config,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"failed to reload config after plugin install, using current config: {err:?}"
|
||||
);
|
||||
self.config.as_ref().clone()
|
||||
}
|
||||
};
|
||||
let plugin_apps = load_plugin_apps(&result.installed_path);
|
||||
let apps_needing_auth = if plugin_apps.is_empty()
|
||||
|| !config.features.enabled(Feature::Apps)
|
||||
{
|
||||
Vec::new()
|
||||
} else {
|
||||
let (all_connectors_result, accessible_connectors_result) = tokio::join!(
|
||||
connectors::list_all_connectors_with_options(&config, true),
|
||||
connectors::list_accessible_connectors_from_mcp_tools_with_options_and_status(
|
||||
&config, true
|
||||
),
|
||||
);
|
||||
|
||||
let all_connectors = match all_connectors_result {
|
||||
Ok(connectors) => filter_disallowed_connectors(merge_plugin_apps(
|
||||
connectors,
|
||||
plugin_apps.clone(),
|
||||
)),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
plugin = result.plugin_id.as_key(),
|
||||
"failed to load app metadata after plugin install: {err:#}"
|
||||
);
|
||||
filter_disallowed_connectors(merge_plugin_apps(
|
||||
connectors::list_cached_all_connectors(&config)
|
||||
.await
|
||||
.unwrap_or_default(),
|
||||
plugin_apps.clone(),
|
||||
))
|
||||
}
|
||||
};
|
||||
let (accessible_connectors, codex_apps_ready) =
|
||||
match accessible_connectors_result {
|
||||
Ok(status) => (status.connectors, status.codex_apps_ready),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
plugin = result.plugin_id.as_key(),
|
||||
"failed to load accessible apps after plugin install: {err:#}"
|
||||
);
|
||||
(
|
||||
connectors::list_cached_accessible_connectors_from_mcp_tools(
|
||||
&config,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default(),
|
||||
false,
|
||||
)
|
||||
}
|
||||
};
|
||||
if !codex_apps_ready {
|
||||
warn!(
|
||||
plugin = result.plugin_id.as_key(),
|
||||
"codex_apps MCP not ready after plugin install; skipping appsNeedingAuth check"
|
||||
);
|
||||
}
|
||||
|
||||
Self::plugin_apps_needing_auth(
|
||||
&all_connectors,
|
||||
&accessible_connectors,
|
||||
&plugin_apps,
|
||||
codex_apps_ready,
|
||||
)
|
||||
};
|
||||
|
||||
plugins_manager.clear_cache();
|
||||
self.thread_manager.skills_manager().clear_cache();
|
||||
self.outgoing
|
||||
.send_response(request_id, PluginInstallResponse {})
|
||||
.send_response(request_id, PluginInstallResponse { apps_needing_auth })
|
||||
.await;
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -5062,6 +5268,10 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
|
||||
match err {
|
||||
CorePluginInstallError::Marketplace(err) => {
|
||||
self.send_marketplace_error(request_id, err, "install plugin")
|
||||
.await;
|
||||
}
|
||||
CorePluginInstallError::Config(err) => {
|
||||
self.send_internal_error(
|
||||
request_id,
|
||||
@@ -5076,7 +5286,13 @@ impl CodexMessageProcessor {
|
||||
)
|
||||
.await;
|
||||
}
|
||||
CorePluginInstallError::Marketplace(_) | CorePluginInstallError::Store(_) => {}
|
||||
CorePluginInstallError::Store(err) => {
|
||||
self.send_internal_error(
|
||||
request_id,
|
||||
format!("failed to install plugin: {err}"),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7267,6 +7483,35 @@ mod tests {
|
||||
validate_dynamic_tools(&tools).expect("valid schema");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_apps_needing_auth_returns_empty_when_codex_apps_is_not_ready() {
|
||||
let all_connectors = vec![AppInfo {
|
||||
id: "alpha".to_string(),
|
||||
name: "Alpha".to_string(),
|
||||
description: Some("Alpha connector".to_string()),
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
branding: None,
|
||||
app_metadata: None,
|
||||
labels: None,
|
||||
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}];
|
||||
|
||||
assert_eq!(
|
||||
CodexMessageProcessor::plugin_apps_needing_auth(
|
||||
&all_connectors,
|
||||
&[],
|
||||
&[AppConnectorId("alpha".to_string())],
|
||||
false,
|
||||
),
|
||||
Vec::<AppSummary>::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_resume_override_mismatches_includes_service_tier() {
|
||||
let request = ThreadResumeParams {
|
||||
|
||||
@@ -163,7 +163,6 @@ fn map_network_requirements_to_api(
|
||||
socks_port: network.socks_port,
|
||||
allow_upstream_proxy: network.allow_upstream_proxy,
|
||||
dangerously_allow_non_loopback_proxy: network.dangerously_allow_non_loopback_proxy,
|
||||
dangerously_allow_non_loopback_admin: network.dangerously_allow_non_loopback_admin,
|
||||
dangerously_allow_all_unix_sockets: network.dangerously_allow_all_unix_sockets,
|
||||
allowed_domains: network.allowed_domains,
|
||||
denied_domains: network.denied_domains,
|
||||
@@ -230,7 +229,6 @@ mod tests {
|
||||
socks_port: Some(1080),
|
||||
allow_upstream_proxy: Some(false),
|
||||
dangerously_allow_non_loopback_proxy: Some(false),
|
||||
dangerously_allow_non_loopback_admin: Some(false),
|
||||
dangerously_allow_all_unix_sockets: Some(true),
|
||||
allowed_domains: Some(vec!["api.openai.com".to_string()]),
|
||||
denied_domains: Some(vec!["example.com".to_string()]),
|
||||
@@ -275,7 +273,6 @@ mod tests {
|
||||
socks_port: Some(1080),
|
||||
allow_upstream_proxy: Some(false),
|
||||
dangerously_allow_non_loopback_proxy: Some(false),
|
||||
dangerously_allow_non_loopback_admin: Some(false),
|
||||
dangerously_allow_all_unix_sockets: Some(true),
|
||||
allowed_domains: Some(vec!["api.openai.com".to_string()]),
|
||||
denied_domains: Some(vec!["example.com".to_string()]),
|
||||
|
||||
@@ -124,6 +124,25 @@ enum ShutdownAction {
|
||||
Finish,
|
||||
}
|
||||
|
||||
async fn shutdown_signal() -> IoResult<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use tokio::signal::unix::SignalKind;
|
||||
use tokio::signal::unix::signal;
|
||||
|
||||
let mut term = signal(SignalKind::terminate())?;
|
||||
tokio::select! {
|
||||
ctrl_c_result = tokio::signal::ctrl_c() => ctrl_c_result,
|
||||
_ = term.recv() => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
tokio::signal::ctrl_c().await
|
||||
}
|
||||
}
|
||||
|
||||
impl ShutdownState {
|
||||
fn requested(&self) -> bool {
|
||||
self.requested
|
||||
@@ -133,7 +152,7 @@ impl ShutdownState {
|
||||
self.forced
|
||||
}
|
||||
|
||||
fn on_ctrl_c(&mut self, connection_count: usize, running_turn_count: usize) {
|
||||
fn on_signal(&mut self, connection_count: usize, running_turn_count: usize) {
|
||||
if self.requested {
|
||||
self.forced = true;
|
||||
return;
|
||||
@@ -142,7 +161,7 @@ impl ShutdownState {
|
||||
self.requested = true;
|
||||
self.last_logged_running_turn_count = None;
|
||||
info!(
|
||||
"received Ctrl-C; entering graceful restart drain (connections={}, runningAssistantTurns={}, requests still accepted until no assistant turns are running)",
|
||||
"received shutdown signal; entering graceful restart drain (connections={}, runningAssistantTurns={}, requests still accepted until no assistant turns are running)",
|
||||
connection_count, running_turn_count,
|
||||
);
|
||||
}
|
||||
@@ -155,11 +174,11 @@ impl ShutdownState {
|
||||
if self.forced || running_turn_count == 0 {
|
||||
if self.forced {
|
||||
info!(
|
||||
"received second Ctrl-C; forcing restart with {running_turn_count} running assistant turn(s) and {connection_count} connection(s)"
|
||||
"received second shutdown signal; forcing restart with {running_turn_count} running assistant turn(s) and {connection_count} connection(s)"
|
||||
);
|
||||
} else {
|
||||
info!(
|
||||
"Ctrl-C restart: no assistant turns running; stopping acceptor and disconnecting {connection_count} connection(s)"
|
||||
"shutdown signal restart: no assistant turns running; stopping acceptor and disconnecting {connection_count} connection(s)"
|
||||
);
|
||||
}
|
||||
return ShutdownAction::Finish;
|
||||
@@ -167,7 +186,7 @@ impl ShutdownState {
|
||||
|
||||
if self.last_logged_running_turn_count != Some(running_turn_count) {
|
||||
info!(
|
||||
"Ctrl-C restart: waiting for {running_turn_count} running assistant turn(s) to finish"
|
||||
"shutdown signal restart: waiting for {running_turn_count} running assistant turn(s) to finish"
|
||||
);
|
||||
self.last_logged_running_turn_count = Some(running_turn_count);
|
||||
}
|
||||
@@ -359,8 +378,7 @@ pub async fn run_main_with_transport(
|
||||
};
|
||||
let single_client_mode = matches!(&transport_runtime, TransportRuntime::Stdio);
|
||||
let shutdown_when_no_connections = single_client_mode;
|
||||
let graceful_ctrl_c_restart_enabled = !single_client_mode;
|
||||
|
||||
let graceful_signal_restart_enabled = !single_client_mode;
|
||||
// Parse CLI overrides once and derive the base Config eagerly so later
|
||||
// components do not need to work with raw TOML values.
|
||||
let cli_kv_overrides = cli_config_overrides.parse_overrides().map_err(|e| {
|
||||
@@ -614,14 +632,14 @@ pub async fn run_main_with_transport(
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
ctrl_c_result = tokio::signal::ctrl_c(), if graceful_ctrl_c_restart_enabled && !shutdown_state.forced() => {
|
||||
if let Err(err) = ctrl_c_result {
|
||||
warn!("failed to listen for Ctrl-C during graceful restart drain: {err}");
|
||||
shutdown_signal_result = shutdown_signal(), if graceful_signal_restart_enabled && !shutdown_state.forced() => {
|
||||
if let Err(err) = shutdown_signal_result {
|
||||
warn!("failed to listen for shutdown signal during graceful restart drain: {err}");
|
||||
}
|
||||
let running_turn_count = *running_turn_count_rx.borrow();
|
||||
shutdown_state.on_ctrl_c(connections.len(), running_turn_count);
|
||||
shutdown_state.on_signal(connections.len(), running_turn_count);
|
||||
}
|
||||
changed = running_turn_count_rx.changed(), if graceful_ctrl_c_restart_enabled && shutdown_state.requested() => {
|
||||
changed = running_turn_count_rx.changed(), if graceful_signal_restart_enabled && shutdown_state.requested() => {
|
||||
if changed.is_err() {
|
||||
warn!("running-turn watcher closed during graceful restart drain");
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::LoginAccountParams;
|
||||
use codex_app_server_protocol::MockExperimentalMethodParams;
|
||||
use codex_app_server_protocol::ModelListParams;
|
||||
use codex_app_server_protocol::PluginInstallParams;
|
||||
use codex_app_server_protocol::PluginListParams;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ReviewStartParams;
|
||||
use codex_app_server_protocol::ServerRequest;
|
||||
@@ -439,6 +441,32 @@ impl McpProcess {
|
||||
self.send_request("skills/list", params).await
|
||||
}
|
||||
|
||||
/// Send a `plugin/install` JSON-RPC request.
|
||||
pub async fn send_plugin_install_request(
|
||||
&mut self,
|
||||
params: PluginInstallParams,
|
||||
) -> anyhow::Result<i64> {
|
||||
let params = Some(serde_json::to_value(params)?);
|
||||
self.send_request("plugin/install", params).await
|
||||
}
|
||||
|
||||
/// Send a `plugin/list` JSON-RPC request.
|
||||
pub async fn send_plugin_list_request(
|
||||
&mut self,
|
||||
params: PluginListParams,
|
||||
) -> anyhow::Result<i64> {
|
||||
let params = Some(serde_json::to_value(params)?);
|
||||
self.send_request("plugin/list", params).await
|
||||
}
|
||||
|
||||
/// Send a JSON-RPC request with raw params for protocol-level validation tests.
|
||||
pub async fn send_raw_request(
|
||||
&mut self,
|
||||
method: &str,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> anyhow::Result<i64> {
|
||||
self.send_request(method, params).await
|
||||
}
|
||||
/// Send a `collaborationMode/list` JSON-RPC request.
|
||||
pub async fn send_list_collaboration_modes_request(
|
||||
&mut self,
|
||||
|
||||
@@ -97,6 +97,7 @@ async fn list_apps_uses_thread_feature_flag_when_thread_id_is_provided() -> Resu
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}];
|
||||
let tools = vec![connector_tool("beta", "Beta App")?];
|
||||
let (server_url, server_handle) =
|
||||
@@ -199,6 +200,7 @@ async fn list_apps_reports_is_enabled_from_config() -> Result<()> {
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}];
|
||||
let tools = vec![connector_tool("beta", "Beta App")?];
|
||||
let (server_url, server_handle) =
|
||||
@@ -308,6 +310,7 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<(
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
AppInfo {
|
||||
id: "beta".to_string(),
|
||||
@@ -322,6 +325,7 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<(
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -370,6 +374,7 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<(
|
||||
install_url: Some("https://chatgpt.com/apps/beta-app/beta".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}];
|
||||
|
||||
let first_update = read_app_list_updated_notification(&mut mcp).await?;
|
||||
@@ -389,6 +394,7 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<(
|
||||
install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
AppInfo {
|
||||
id: "alpha".to_string(),
|
||||
@@ -403,6 +409,7 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<(
|
||||
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -443,6 +450,7 @@ async fn list_apps_waits_for_accessible_data_before_emitting_directory_updates()
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
AppInfo {
|
||||
id: "beta".to_string(),
|
||||
@@ -457,6 +465,7 @@ async fn list_apps_waits_for_accessible_data_before_emitting_directory_updates()
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -516,6 +525,7 @@ async fn list_apps_waits_for_accessible_data_before_emitting_directory_updates()
|
||||
install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
AppInfo {
|
||||
id: "alpha".to_string(),
|
||||
@@ -530,6 +540,7 @@ async fn list_apps_waits_for_accessible_data_before_emitting_directory_updates()
|
||||
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -564,6 +575,7 @@ async fn list_apps_does_not_emit_empty_interim_updates() -> Result<()> {
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}];
|
||||
let (server_url, server_handle) = start_apps_server_with_delays(
|
||||
connectors.clone(),
|
||||
@@ -619,6 +631,7 @@ async fn list_apps_does_not_emit_empty_interim_updates() -> Result<()> {
|
||||
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}];
|
||||
|
||||
let update = read_app_list_updated_notification(&mut mcp).await?;
|
||||
@@ -653,6 +666,7 @@ async fn list_apps_paginates_results() -> Result<()> {
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
AppInfo {
|
||||
id: "beta".to_string(),
|
||||
@@ -667,6 +681,7 @@ async fn list_apps_paginates_results() -> Result<()> {
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -724,6 +739,7 @@ async fn list_apps_paginates_results() -> Result<()> {
|
||||
install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}];
|
||||
|
||||
assert_eq!(first_page, expected_first);
|
||||
@@ -767,6 +783,7 @@ async fn list_apps_paginates_results() -> Result<()> {
|
||||
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}];
|
||||
|
||||
assert_eq!(second_page, expected_second);
|
||||
@@ -791,6 +808,7 @@ async fn list_apps_force_refetch_preserves_previous_cache_on_failure() -> Result
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}];
|
||||
let tools = vec![connector_tool("beta", "Beta App")?];
|
||||
let (server_url, server_handle) =
|
||||
@@ -895,6 +913,7 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
AppInfo {
|
||||
id: "beta".to_string(),
|
||||
@@ -909,6 +928,7 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
];
|
||||
let initial_tools = vec![connector_tool("beta", "Beta App")?];
|
||||
@@ -958,6 +978,7 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu
|
||||
install_url: Some("https://chatgpt.com/apps/beta-app/beta".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}]
|
||||
);
|
||||
|
||||
@@ -978,6 +999,7 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu
|
||||
install_url: Some("https://chatgpt.com/apps/beta-app/beta".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
AppInfo {
|
||||
id: "alpha".to_string(),
|
||||
@@ -992,6 +1014,7 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu
|
||||
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
]
|
||||
);
|
||||
@@ -1021,6 +1044,7 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}]);
|
||||
server_control.set_tools(Vec::new());
|
||||
|
||||
@@ -1050,6 +1074,7 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu
|
||||
install_url: Some("https://chatgpt.com/apps/beta-app/beta".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
AppInfo {
|
||||
id: "alpha".to_string(),
|
||||
@@ -1064,6 +1089,7 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu
|
||||
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
]
|
||||
);
|
||||
@@ -1091,6 +1117,7 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu
|
||||
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}];
|
||||
let second_update = read_app_list_updated_notification(&mut mcp).await?;
|
||||
assert_eq!(second_update.data, expected_final);
|
||||
|
||||
@@ -83,6 +83,57 @@ async fn websocket_transport_second_ctrl_c_forces_exit_while_turn_running() -> R
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_transport_sigterm_waits_for_running_turn_before_exit() -> Result<()> {
|
||||
let GracefulCtrlCFixture {
|
||||
_codex_home,
|
||||
_server,
|
||||
mut process,
|
||||
mut ws,
|
||||
} = start_ctrl_c_restart_fixture(Duration::from_secs(3)).await?;
|
||||
|
||||
send_sigterm(&process)?;
|
||||
assert_process_does_not_exit_within(&mut process, Duration::from_millis(300)).await?;
|
||||
|
||||
let status = wait_for_process_exit_within(
|
||||
&mut process,
|
||||
Duration::from_secs(10),
|
||||
"timed out waiting for graceful SIGTERM restart shutdown",
|
||||
)
|
||||
.await?;
|
||||
assert!(status.success(), "expected graceful exit, got {status}");
|
||||
|
||||
expect_websocket_disconnect(&mut ws).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_transport_second_sigterm_forces_exit_while_turn_running() -> Result<()> {
|
||||
let GracefulCtrlCFixture {
|
||||
_codex_home,
|
||||
_server,
|
||||
mut process,
|
||||
mut ws,
|
||||
} = start_ctrl_c_restart_fixture(Duration::from_secs(3)).await?;
|
||||
|
||||
send_sigterm(&process)?;
|
||||
assert_process_does_not_exit_within(&mut process, Duration::from_millis(300)).await?;
|
||||
|
||||
send_sigterm(&process)?;
|
||||
let status = wait_for_process_exit_within(
|
||||
&mut process,
|
||||
Duration::from_secs(2),
|
||||
"timed out waiting for forced SIGTERM restart shutdown",
|
||||
)
|
||||
.await?;
|
||||
assert!(status.success(), "expected graceful exit, got {status}");
|
||||
|
||||
expect_websocket_disconnect(&mut ws).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct GracefulCtrlCFixture {
|
||||
_codex_home: TempDir,
|
||||
_server: wiremock::MockServer,
|
||||
@@ -180,16 +231,24 @@ async fn wait_for_responses_post(server: &wiremock::MockServer, wait_for: Durati
|
||||
}
|
||||
|
||||
fn send_sigint(process: &Child) -> Result<()> {
|
||||
send_signal(process, "-INT")
|
||||
}
|
||||
|
||||
fn send_sigterm(process: &Child) -> Result<()> {
|
||||
send_signal(process, "-TERM")
|
||||
}
|
||||
|
||||
fn send_signal(process: &Child, signal: &str) -> Result<()> {
|
||||
let pid = process
|
||||
.id()
|
||||
.context("websocket app-server process has no pid")?;
|
||||
let status = StdCommand::new("kill")
|
||||
.arg("-INT")
|
||||
.arg(signal)
|
||||
.arg(pid.to_string())
|
||||
.status()
|
||||
.context("failed to invoke kill -INT")?;
|
||||
.with_context(|| format!("failed to invoke kill {signal}"))?;
|
||||
if !status.success() {
|
||||
bail!("kill -INT exited with {status}");
|
||||
bail!("kill {signal} exited with {status}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ mod mcp_server_elicitation;
|
||||
mod model_list;
|
||||
mod output_schema;
|
||||
mod plan_item;
|
||||
mod plugin_install;
|
||||
mod plugin_list;
|
||||
mod rate_limits;
|
||||
mod realtime_conversation;
|
||||
mod request_user_input;
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
use std::borrow::Cow;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use app_test_support::ChatGptAuthFixture;
|
||||
use app_test_support::McpProcess;
|
||||
use app_test_support::to_response;
|
||||
use app_test_support::write_chatgpt_auth;
|
||||
use axum::Json;
|
||||
use axum::Router;
|
||||
use axum::extract::State;
|
||||
use axum::http::HeaderMap;
|
||||
use axum::http::StatusCode;
|
||||
use axum::http::Uri;
|
||||
use axum::http::header::AUTHORIZATION;
|
||||
use axum::routing::get;
|
||||
use codex_app_server_protocol::AppInfo;
|
||||
use codex_app_server_protocol::AppSummary;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::PluginInstallParams;
|
||||
use codex_app_server_protocol::PluginInstallResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_core::auth::AuthCredentialsStoreMode;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use rmcp::handler::server::ServerHandler;
|
||||
use rmcp::model::JsonObject;
|
||||
use rmcp::model::ListToolsResult;
|
||||
use rmcp::model::Meta;
|
||||
use rmcp::model::ServerCapabilities;
|
||||
use rmcp::model::ServerInfo;
|
||||
use rmcp::model::Tool;
|
||||
use rmcp::model::ToolAnnotations;
|
||||
use rmcp::transport::StreamableHttpServerConfig;
|
||||
use rmcp::transport::StreamableHttpService;
|
||||
use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio::time::timeout;
|
||||
|
||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_install_rejects_relative_marketplace_paths() -> 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_raw_request(
|
||||
"plugin/install",
|
||||
Some(serde_json::json!({
|
||||
"marketplacePath": "relative-marketplace.json",
|
||||
"pluginName": "missing-plugin",
|
||||
})),
|
||||
)
|
||||
.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("Invalid request"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_install_returns_invalid_request_for_missing_marketplace_file() -> 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: AbsolutePathBuf::try_from(
|
||||
codex_home.path().join("missing-marketplace.json"),
|
||||
)?,
|
||||
plugin_name: "missing-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("marketplace file"));
|
||||
assert!(err.error.message.contains("does not exist"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_install_returns_apps_needing_auth() -> Result<()> {
|
||||
let connectors = vec![
|
||||
AppInfo {
|
||||
id: "alpha".to_string(),
|
||||
name: "Alpha".to_string(),
|
||||
description: Some("Alpha connector".to_string()),
|
||||
logo_url: Some("https://example.com/alpha.png".to_string()),
|
||||
logo_url_dark: None,
|
||||
distribution_channel: Some("featured".to_string()),
|
||||
branding: None,
|
||||
app_metadata: None,
|
||||
labels: None,
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
AppInfo {
|
||||
id: "beta".to_string(),
|
||||
name: "Beta".to_string(),
|
||||
description: Some("Beta connector".to_string()),
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
branding: None,
|
||||
app_metadata: None,
|
||||
labels: None,
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
];
|
||||
let tools = vec![connector_tool("beta", "Beta App")?];
|
||||
let (server_url, server_handle) = start_apps_server(connectors, tools).await?;
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
write_connectors_config(codex_home.path(), &server_url)?;
|
||||
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",
|
||||
)?;
|
||||
write_plugin_source(repo_root.path(), "sample-plugin", &["alpha", "beta"])?;
|
||||
let marketplace_path =
|
||||
AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?;
|
||||
|
||||
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(),
|
||||
})
|
||||
.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,
|
||||
PluginInstallResponse {
|
||||
apps_needing_auth: vec![AppSummary {
|
||||
id: "alpha".to_string(),
|
||||
name: "Alpha".to_string(),
|
||||
description: Some("Alpha connector".to_string()),
|
||||
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
|
||||
}],
|
||||
}
|
||||
);
|
||||
|
||||
server_handle.abort();
|
||||
let _ = server_handle.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_install_filters_disallowed_apps_needing_auth() -> Result<()> {
|
||||
let connectors = vec![AppInfo {
|
||||
id: "alpha".to_string(),
|
||||
name: "Alpha".to_string(),
|
||||
description: Some("Alpha connector".to_string()),
|
||||
logo_url: Some("https://example.com/alpha.png".to_string()),
|
||||
logo_url_dark: None,
|
||||
distribution_channel: Some("featured".to_string()),
|
||||
branding: None,
|
||||
app_metadata: None,
|
||||
labels: None,
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}];
|
||||
let (server_url, server_handle) = start_apps_server(connectors, Vec::new()).await?;
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
write_connectors_config(codex_home.path(), &server_url)?;
|
||||
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",
|
||||
)?;
|
||||
write_plugin_source(
|
||||
repo_root.path(),
|
||||
"sample-plugin",
|
||||
&["alpha", "asdk_app_6938a94a61d881918ef32cb999ff937c"],
|
||||
)?;
|
||||
let marketplace_path =
|
||||
AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?;
|
||||
|
||||
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(),
|
||||
})
|
||||
.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,
|
||||
PluginInstallResponse {
|
||||
apps_needing_auth: vec![AppSummary {
|
||||
id: "alpha".to_string(),
|
||||
name: "Alpha".to_string(),
|
||||
description: Some("Alpha connector".to_string()),
|
||||
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
|
||||
}],
|
||||
}
|
||||
);
|
||||
|
||||
server_handle.abort();
|
||||
let _ = server_handle.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppsServerState {
|
||||
response: Arc<StdMutex<serde_json::Value>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct PluginInstallMcpServer {
|
||||
tools: Arc<StdMutex<Vec<Tool>>>,
|
||||
}
|
||||
|
||||
impl ServerHandler for PluginInstallMcpServer {
|
||||
fn get_info(&self) -> ServerInfo {
|
||||
ServerInfo {
|
||||
capabilities: ServerCapabilities::builder().enable_tools().build(),
|
||||
..ServerInfo::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn list_tools(
|
||||
&self,
|
||||
_request: Option<rmcp::model::PaginatedRequestParams>,
|
||||
_context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
|
||||
) -> impl std::future::Future<Output = Result<ListToolsResult, rmcp::ErrorData>> + Send + '_
|
||||
{
|
||||
let tools = self.tools.clone();
|
||||
async move {
|
||||
let tools = tools
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.clone();
|
||||
Ok(ListToolsResult {
|
||||
tools,
|
||||
next_cursor: None,
|
||||
meta: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn start_apps_server(
|
||||
connectors: Vec<AppInfo>,
|
||||
tools: Vec<Tool>,
|
||||
) -> Result<(String, JoinHandle<()>)> {
|
||||
let state = Arc::new(AppsServerState {
|
||||
response: Arc::new(StdMutex::new(
|
||||
json!({ "apps": connectors, "next_token": null }),
|
||||
)),
|
||||
});
|
||||
let tools = Arc::new(StdMutex::new(tools));
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let addr = listener.local_addr()?;
|
||||
let mcp_service = StreamableHttpService::new(
|
||||
{
|
||||
let tools = tools.clone();
|
||||
move || {
|
||||
Ok(PluginInstallMcpServer {
|
||||
tools: tools.clone(),
|
||||
})
|
||||
}
|
||||
},
|
||||
Arc::new(LocalSessionManager::default()),
|
||||
StreamableHttpServerConfig::default(),
|
||||
);
|
||||
let router = Router::new()
|
||||
.route("/connectors/directory/list", get(list_directory_connectors))
|
||||
.route(
|
||||
"/connectors/directory/list_workspace",
|
||||
get(list_directory_connectors),
|
||||
)
|
||||
.with_state(state)
|
||||
.nest_service("/api/codex/apps", mcp_service);
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, router).await;
|
||||
});
|
||||
|
||||
Ok((format!("http://{addr}"), handle))
|
||||
}
|
||||
|
||||
async fn list_directory_connectors(
|
||||
State(state): State<Arc<AppsServerState>>,
|
||||
headers: HeaderMap,
|
||||
uri: Uri,
|
||||
) -> Result<impl axum::response::IntoResponse, StatusCode> {
|
||||
let bearer_ok = headers
|
||||
.get(AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| value == "Bearer chatgpt-token");
|
||||
let account_ok = headers
|
||||
.get("chatgpt-account-id")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| value == "account-123");
|
||||
let external_logos_ok = uri
|
||||
.query()
|
||||
.is_some_and(|query| query.split('&').any(|pair| pair == "external_logos=true"));
|
||||
|
||||
if !bearer_ok || !account_ok {
|
||||
Err(StatusCode::UNAUTHORIZED)
|
||||
} else if !external_logos_ok {
|
||||
Err(StatusCode::BAD_REQUEST)
|
||||
} else {
|
||||
let response = state
|
||||
.response
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.clone();
|
||||
Ok(Json(response))
|
||||
}
|
||||
}
|
||||
|
||||
fn connector_tool(connector_id: &str, connector_name: &str) -> Result<Tool> {
|
||||
let schema: JsonObject = serde_json::from_value(json!({
|
||||
"type": "object",
|
||||
"additionalProperties": false
|
||||
}))?;
|
||||
let mut tool = Tool::new(
|
||||
Cow::Owned(format!("connector_{connector_id}")),
|
||||
Cow::Borrowed("Connector test tool"),
|
||||
Arc::new(schema),
|
||||
);
|
||||
tool.annotations = Some(ToolAnnotations::new().read_only(true));
|
||||
|
||||
let mut meta = Meta::new();
|
||||
meta.0
|
||||
.insert("connector_id".to_string(), json!(connector_id));
|
||||
meta.0
|
||||
.insert("connector_name".to_string(), json!(connector_name));
|
||||
tool.meta = Some(meta);
|
||||
Ok(tool)
|
||||
}
|
||||
|
||||
fn write_connectors_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}"
|
||||
mcp_oauth_credentials_store = "file"
|
||||
|
||||
[features]
|
||||
connectors = true
|
||||
"#
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn write_plugin_marketplace(
|
||||
repo_root: &std::path::Path,
|
||||
marketplace_name: &str,
|
||||
plugin_name: &str,
|
||||
source_path: &str,
|
||||
) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(repo_root.join(".git"))?;
|
||||
std::fs::create_dir_all(repo_root.join(".agents/plugins"))?;
|
||||
std::fs::write(
|
||||
repo_root.join(".agents/plugins/marketplace.json"),
|
||||
format!(
|
||||
r#"{{
|
||||
"name": "{marketplace_name}",
|
||||
"plugins": [
|
||||
{{
|
||||
"name": "{plugin_name}",
|
||||
"source": {{
|
||||
"source": "local",
|
||||
"path": "{source_path}"
|
||||
}}
|
||||
}}
|
||||
]
|
||||
}}"#
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn write_plugin_source(
|
||||
repo_root: &std::path::Path,
|
||||
plugin_name: &str,
|
||||
app_ids: &[&str],
|
||||
) -> Result<()> {
|
||||
let plugin_root = repo_root.join(".agents/plugins").join(plugin_name);
|
||||
std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?;
|
||||
std::fs::write(
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
format!(r#"{{"name":"{plugin_name}"}}"#),
|
||||
)?;
|
||||
|
||||
let apps = app_ids
|
||||
.iter()
|
||||
.map(|app_id| ((*app_id).to_string(), json!({ "id": app_id })))
|
||||
.collect::<serde_json::Map<_, _>>();
|
||||
std::fs::write(
|
||||
plugin_root.join(".app.json"),
|
||||
serde_json::to_vec_pretty(&json!({ "apps": apps }))?,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use app_test_support::McpProcess;
|
||||
use app_test_support::to_response;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::PluginListParams;
|
||||
use codex_app_server_protocol::PluginListResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_core::config::set_project_trust_level;
|
||||
use codex_protocol::config_types::TrustLevel;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
|
||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_list_returns_invalid_request_for_invalid_marketplace_file() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let repo_root = TempDir::new()?;
|
||||
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"),
|
||||
"{not json",
|
||||
)?;
|
||||
|
||||
let home = codex_home.path().to_string_lossy().into_owned();
|
||||
let mut mcp = McpProcess::new_with_env(
|
||||
codex_home.path(),
|
||||
&[
|
||||
("HOME", Some(home.as_str())),
|
||||
("USERPROFILE", Some(home.as_str())),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_list_request(PluginListParams {
|
||||
cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]),
|
||||
})
|
||||
.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("invalid marketplace file"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_list_rejects_relative_cwds() -> 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_raw_request(
|
||||
"plugin/list",
|
||||
Some(serde_json::json!({
|
||||
"cwds": ["relative-root"],
|
||||
})),
|
||||
)
|
||||
.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("Invalid request"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_list_accepts_omitted_cwds() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
std::fs::create_dir_all(codex_home.path().join(".agents/plugins"))?;
|
||||
std::fs::write(
|
||||
codex_home.path().join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "codex-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "home-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./home-plugin"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)?;
|
||||
let home = codex_home.path().to_string_lossy().into_owned();
|
||||
let mut mcp = McpProcess::new_with_env(
|
||||
codex_home.path(),
|
||||
&[
|
||||
("HOME", Some(home.as_str())),
|
||||
("USERPROFILE", Some(home.as_str())),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_list_request(PluginListParams { cwds: None })
|
||||
.await?;
|
||||
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let _: PluginListResponse = to_response(response)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_list_includes_enabled_state_from_config() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let repo_root = TempDir::new()?;
|
||||
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"),
|
||||
r#"{
|
||||
"name": "codex-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "enabled-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./enabled-plugin"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "disabled-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./disabled-plugin"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)?;
|
||||
std::fs::write(
|
||||
codex_home.path().join("config.toml"),
|
||||
r#"[features]
|
||||
plugins = true
|
||||
|
||||
[plugins."enabled-plugin@codex-curated"]
|
||||
enabled = true
|
||||
|
||||
[plugins."disabled-plugin@codex-curated"]
|
||||
enabled = false
|
||||
"#,
|
||||
)?;
|
||||
|
||||
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: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: PluginListResponse = to_response(response)?;
|
||||
|
||||
let marketplace = response
|
||||
.marketplaces
|
||||
.into_iter()
|
||||
.find(|marketplace| {
|
||||
marketplace.path == repo_root.path().join(".agents/plugins/marketplace.json")
|
||||
})
|
||||
.expect("expected repo marketplace entry");
|
||||
|
||||
assert_eq!(marketplace.name, "codex-curated");
|
||||
assert_eq!(marketplace.plugins.len(), 2);
|
||||
assert_eq!(marketplace.plugins[0].name, "enabled-plugin");
|
||||
assert_eq!(marketplace.plugins[0].enabled, true);
|
||||
assert_eq!(marketplace.plugins[1].name, "disabled-plugin");
|
||||
assert_eq!(marketplace.plugins[1].enabled, false);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_list_uses_home_config_for_enabled_state() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
std::fs::create_dir_all(codex_home.path().join(".agents/plugins"))?;
|
||||
std::fs::write(
|
||||
codex_home.path().join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "codex-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "shared-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./shared-plugin"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)?;
|
||||
std::fs::write(
|
||||
codex_home.path().join("config.toml"),
|
||||
r#"[features]
|
||||
plugins = true
|
||||
|
||||
[plugins."shared-plugin@codex-curated"]
|
||||
enabled = true
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let workspace_enabled = TempDir::new()?;
|
||||
std::fs::create_dir_all(workspace_enabled.path().join(".git"))?;
|
||||
std::fs::create_dir_all(workspace_enabled.path().join(".agents/plugins"))?;
|
||||
std::fs::write(
|
||||
workspace_enabled
|
||||
.path()
|
||||
.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "codex-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "shared-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./shared-plugin"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)?;
|
||||
std::fs::create_dir_all(workspace_enabled.path().join(".codex"))?;
|
||||
std::fs::write(
|
||||
workspace_enabled.path().join(".codex/config.toml"),
|
||||
r#"[plugins."shared-plugin@codex-curated"]
|
||||
enabled = false
|
||||
"#,
|
||||
)?;
|
||||
set_project_trust_level(
|
||||
codex_home.path(),
|
||||
workspace_enabled.path(),
|
||||
TrustLevel::Trusted,
|
||||
)?;
|
||||
|
||||
let workspace_default = TempDir::new()?;
|
||||
let home = codex_home.path().to_string_lossy().into_owned();
|
||||
let mut mcp = McpProcess::new_with_env(
|
||||
codex_home.path(),
|
||||
&[
|
||||
("HOME", Some(home.as_str())),
|
||||
("USERPROFILE", Some(home.as_str())),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_list_request(PluginListParams {
|
||||
cwds: Some(vec![
|
||||
AbsolutePathBuf::try_from(workspace_enabled.path())?,
|
||||
AbsolutePathBuf::try_from(workspace_default.path())?,
|
||||
]),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: PluginListResponse = to_response(response)?;
|
||||
|
||||
let shared_plugin = response
|
||||
.marketplaces
|
||||
.iter()
|
||||
.flat_map(|marketplace| marketplace.plugins.iter())
|
||||
.find(|plugin| plugin.name == "shared-plugin")
|
||||
.expect("expected shared-plugin entry");
|
||||
assert_eq!(shared_plugin.enabled, true);
|
||||
Ok(())
|
||||
}
|
||||
@@ -36,6 +36,7 @@ use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
|
||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const STARTUP_CONTEXT_HEADER: &str = "Startup context from Codex.";
|
||||
|
||||
#[tokio::test]
|
||||
async fn realtime_conversation_streams_v2_notifications() -> Result<()> {
|
||||
@@ -114,6 +115,18 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> {
|
||||
assert_eq!(started.thread_id, thread_start.thread.id);
|
||||
assert!(started.session_id.is_some());
|
||||
|
||||
let startup_context_request = realtime_server.wait_for_request(0, 0).await;
|
||||
assert_eq!(
|
||||
startup_context_request.body_json()["type"].as_str(),
|
||||
Some("session.update")
|
||||
);
|
||||
assert!(
|
||||
startup_context_request.body_json()["session"]["instructions"]
|
||||
.as_str()
|
||||
.context("expected startup context instructions")?
|
||||
.contains(STARTUP_CONTEXT_HEADER)
|
||||
);
|
||||
|
||||
let audio_append_request_id = mcp
|
||||
.send_thread_realtime_append_audio_request(ThreadRealtimeAppendAudioParams {
|
||||
thread_id: started.thread_id.clone(),
|
||||
@@ -183,6 +196,12 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> {
|
||||
connection[0].body_json()["type"].as_str(),
|
||||
Some("session.update")
|
||||
);
|
||||
assert!(
|
||||
connection[0].body_json()["session"]["instructions"]
|
||||
.as_str()
|
||||
.context("expected startup context instructions")?
|
||||
.contains(STARTUP_CONTEXT_HEADER)
|
||||
);
|
||||
let mut request_types = [
|
||||
connection[1].body_json()["type"]
|
||||
.as_str()
|
||||
|
||||
@@ -446,6 +446,7 @@ fn directory_app_to_app_info(app: DirectoryApp) -> AppInfo {
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -483,6 +484,7 @@ mod tests {
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -540,6 +542,7 @@ mod tests {
|
||||
install_url: Some(connector_install_url(id, id)),
|
||||
is_accessible,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -300,7 +300,7 @@ impl RealtimeWebsocketWriter {
|
||||
},
|
||||
},
|
||||
output: SessionAudioOutput {
|
||||
voice: "mundo".to_string(),
|
||||
voice: "fathom".to_string(),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -793,7 +793,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
first_json["session"]["audio"]["output"]["voice"],
|
||||
Value::String("mundo".to_string())
|
||||
Value::String("fathom".to_string())
|
||||
);
|
||||
|
||||
ws.send(Message::Text(
|
||||
|
||||
@@ -136,7 +136,6 @@ pub struct NetworkRequirementsToml {
|
||||
pub socks_port: Option<u16>,
|
||||
pub allow_upstream_proxy: Option<bool>,
|
||||
pub dangerously_allow_non_loopback_proxy: Option<bool>,
|
||||
pub dangerously_allow_non_loopback_admin: Option<bool>,
|
||||
pub dangerously_allow_all_unix_sockets: Option<bool>,
|
||||
pub allowed_domains: Option<Vec<String>>,
|
||||
pub denied_domains: Option<Vec<String>>,
|
||||
@@ -152,7 +151,6 @@ pub struct NetworkConstraints {
|
||||
pub socks_port: Option<u16>,
|
||||
pub allow_upstream_proxy: Option<bool>,
|
||||
pub dangerously_allow_non_loopback_proxy: Option<bool>,
|
||||
pub dangerously_allow_non_loopback_admin: Option<bool>,
|
||||
pub dangerously_allow_all_unix_sockets: Option<bool>,
|
||||
pub allowed_domains: Option<Vec<String>>,
|
||||
pub denied_domains: Option<Vec<String>>,
|
||||
@@ -168,7 +166,6 @@ impl From<NetworkRequirementsToml> for NetworkConstraints {
|
||||
socks_port,
|
||||
allow_upstream_proxy,
|
||||
dangerously_allow_non_loopback_proxy,
|
||||
dangerously_allow_non_loopback_admin,
|
||||
dangerously_allow_all_unix_sockets,
|
||||
allowed_domains,
|
||||
denied_domains,
|
||||
@@ -181,7 +178,6 @@ impl From<NetworkRequirementsToml> for NetworkConstraints {
|
||||
socks_port,
|
||||
allow_upstream_proxy,
|
||||
dangerously_allow_non_loopback_proxy,
|
||||
dangerously_allow_non_loopback_admin,
|
||||
dangerously_allow_all_unix_sockets,
|
||||
allowed_domains,
|
||||
denied_domains,
|
||||
|
||||
@@ -797,9 +797,6 @@
|
||||
"NetworkToml": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"admin_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"allow_local_binding": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -821,9 +818,6 @@
|
||||
"dangerously_allow_all_unix_sockets": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"dangerously_allow_non_loopback_admin": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"dangerously_allow_non_loopback_proxy": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
+146
-48
File diff suppressed because one or more lines are too long
+504
-101
@@ -66,6 +66,7 @@ use codex_hooks::HooksConfig;
|
||||
use codex_network_proxy::NetworkProxy;
|
||||
use codex_network_proxy::NetworkProxyAuditMetadata;
|
||||
use codex_network_proxy::normalize_host;
|
||||
use codex_otel::current_span_trace_id;
|
||||
use codex_otel::current_span_w3c_trace_context;
|
||||
use codex_otel::set_parent_from_w3c_trace_context;
|
||||
use codex_protocol::ThreadId;
|
||||
@@ -205,9 +206,11 @@ use crate::memories;
|
||||
use crate::mentions::build_connector_slug_counts;
|
||||
use crate::mentions::build_skill_name_counts;
|
||||
use crate::mentions::collect_explicit_app_ids;
|
||||
use crate::mentions::collect_explicit_plugin_mentions;
|
||||
use crate::mentions::collect_tool_mentions_from_messages;
|
||||
use crate::network_policy_decision::execpolicy_network_rule_amendment;
|
||||
use crate::plugins::PluginsManager;
|
||||
use crate::plugins::build_plugin_injections;
|
||||
use crate::project_doc::get_user_instructions;
|
||||
use crate::protocol::AgentMessageContentDeltaEvent;
|
||||
use crate::protocol::AgentReasoningSectionBreakEvent;
|
||||
@@ -514,7 +517,7 @@ impl Codex {
|
||||
session_source_clone,
|
||||
skills_manager,
|
||||
plugins_manager,
|
||||
mcp_manager,
|
||||
mcp_manager.clone(),
|
||||
file_watcher,
|
||||
agent_control,
|
||||
)
|
||||
@@ -655,6 +658,7 @@ impl TurnSkillsContext {
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct TurnContext {
|
||||
pub(crate) sub_id: String,
|
||||
pub(crate) trace_id: Option<String>,
|
||||
pub(crate) realtime_active: bool,
|
||||
pub(crate) config: Arc<Config>,
|
||||
pub(crate) auth_manager: Option<Arc<AuthManager>>,
|
||||
@@ -743,6 +747,7 @@ impl TurnContext {
|
||||
|
||||
Self {
|
||||
sub_id: self.sub_id.clone(),
|
||||
trace_id: self.trace_id.clone(),
|
||||
realtime_active: self.realtime_active,
|
||||
config: Arc::new(config),
|
||||
auth_manager: self.auth_manager.clone(),
|
||||
@@ -798,6 +803,7 @@ impl TurnContext {
|
||||
pub(crate) fn to_turn_context_item(&self) -> TurnContextItem {
|
||||
TurnContextItem {
|
||||
turn_id: Some(self.sub_id.clone()),
|
||||
trace_id: self.trace_id.clone(),
|
||||
cwd: self.cwd.clone(),
|
||||
current_date: self.current_date.clone(),
|
||||
timezone: self.timezone.clone(),
|
||||
@@ -1014,7 +1020,6 @@ impl Session {
|
||||
SessionNetworkProxyRuntime {
|
||||
http_addr: proxy.http_addr().to_string(),
|
||||
socks_addr: proxy.socks_addr().to_string(),
|
||||
admin_addr: proxy.admin_addr().to_string(),
|
||||
}
|
||||
};
|
||||
Ok((network_proxy, session_network_proxy))
|
||||
@@ -1127,6 +1132,7 @@ impl Session {
|
||||
let (current_date, timezone) = local_time_context();
|
||||
TurnContext {
|
||||
sub_id,
|
||||
trace_id: current_span_trace_id(),
|
||||
realtime_active: false,
|
||||
config: per_turn_config.clone(),
|
||||
auth_manager: auth_manager_for_context,
|
||||
@@ -1528,8 +1534,8 @@ impl Session {
|
||||
tool_approvals: Mutex::new(ApprovalStore::default()),
|
||||
execve_session_approvals: RwLock::new(HashMap::new()),
|
||||
skills_manager,
|
||||
plugins_manager,
|
||||
mcp_manager,
|
||||
plugins_manager: Arc::clone(&plugins_manager),
|
||||
mcp_manager: Arc::clone(&mcp_manager),
|
||||
file_watcher,
|
||||
agent_control,
|
||||
network_proxy,
|
||||
@@ -1613,6 +1619,7 @@ impl Session {
|
||||
.map(|(name, _)| name.clone())
|
||||
.collect();
|
||||
required_mcp_servers.sort();
|
||||
let tool_plugin_provenance = mcp_manager.tool_plugin_provenance(config.as_ref());
|
||||
{
|
||||
let mut cancel_guard = sess.services.mcp_startup_cancellation_token.lock().await;
|
||||
cancel_guard.cancel();
|
||||
@@ -1627,6 +1634,7 @@ impl Session {
|
||||
sandbox_state,
|
||||
config.codex_home.clone(),
|
||||
codex_apps_tools_cache_key(auth),
|
||||
tool_plugin_provenance,
|
||||
)
|
||||
.await;
|
||||
{
|
||||
@@ -3658,6 +3666,10 @@ impl Session {
|
||||
) {
|
||||
let auth = self.services.auth_manager.auth().await;
|
||||
let config = self.get_config().await;
|
||||
let tool_plugin_provenance = self
|
||||
.services
|
||||
.mcp_manager
|
||||
.tool_plugin_provenance(config.as_ref());
|
||||
let mcp_servers = with_codex_apps_mcp(
|
||||
mcp_servers,
|
||||
self.features.enabled(Feature::Apps),
|
||||
@@ -3685,6 +3697,7 @@ impl Session {
|
||||
sandbox_state,
|
||||
config.codex_home.clone(),
|
||||
codex_apps_tools_cache_key(auth.as_ref()),
|
||||
tool_plugin_provenance,
|
||||
)
|
||||
.await;
|
||||
{
|
||||
@@ -4019,6 +4032,7 @@ mod handlers {
|
||||
use crate::mcp::auth::compute_auth_statuses;
|
||||
use crate::mcp::collect_mcp_snapshot_from_manager;
|
||||
use crate::review_prompts::resolve_review_request;
|
||||
use crate::rollout::RolloutRecorder;
|
||||
use crate::rollout::session_index;
|
||||
use crate::tasks::CompactTask;
|
||||
use crate::tasks::UndoTask;
|
||||
@@ -4041,6 +4055,7 @@ mod handlers {
|
||||
use codex_protocol::protocol::RemoteSkillSummary;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use codex_protocol::protocol::ReviewRequest;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::SkillsListEntry;
|
||||
use codex_protocol::protocol::ThreadNameUpdatedEvent;
|
||||
use codex_protocol::protocol::ThreadRolledBackEvent;
|
||||
@@ -4620,25 +4635,86 @@ mod handlers {
|
||||
}
|
||||
|
||||
let turn_context = sess.new_default_turn_with_sub_id(sub_id).await;
|
||||
let rollout_path = {
|
||||
let recorder = {
|
||||
let guard = sess.services.rollout.lock().await;
|
||||
guard.clone()
|
||||
};
|
||||
let Some(recorder) = recorder else {
|
||||
sess.send_event_raw(Event {
|
||||
id: turn_context.sub_id.clone(),
|
||||
msg: EventMsg::Error(ErrorEvent {
|
||||
message: "thread rollback requires a persisted rollout path".to_string(),
|
||||
codex_error_info: Some(CodexErrorInfo::ThreadRollbackFailed),
|
||||
}),
|
||||
})
|
||||
.await;
|
||||
return;
|
||||
};
|
||||
recorder.rollout_path().to_path_buf()
|
||||
};
|
||||
if let Some(recorder) = {
|
||||
let guard = sess.services.rollout.lock().await;
|
||||
guard.clone()
|
||||
} && let Err(err) = recorder.flush().await
|
||||
{
|
||||
sess.send_event_raw(Event {
|
||||
id: turn_context.sub_id.clone(),
|
||||
msg: EventMsg::Error(ErrorEvent {
|
||||
message: format!(
|
||||
"failed to flush rollout `{}` for rollback replay: {err}",
|
||||
rollout_path.display()
|
||||
),
|
||||
codex_error_info: Some(CodexErrorInfo::ThreadRollbackFailed),
|
||||
}),
|
||||
})
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
let mut history = sess.clone_history().await;
|
||||
// TODO(ccunningham): Fix rollback/backtracking baseline handling.
|
||||
// We clear `reference_context_item` here, but should restore the
|
||||
// post-rollback baseline from the surviving history/rollout instead.
|
||||
// Truncating history should also invalidate/recompute `previous_turn_settings`
|
||||
// so the next regular turn replays any dropped model/realtime
|
||||
// instructions.
|
||||
history.drop_last_n_user_turns(num_turns);
|
||||
let initial_history =
|
||||
match RolloutRecorder::get_rollout_history(rollout_path.as_path()).await {
|
||||
Ok(history) => history,
|
||||
Err(err) => {
|
||||
sess.send_event_raw(Event {
|
||||
id: turn_context.sub_id.clone(),
|
||||
msg: EventMsg::Error(ErrorEvent {
|
||||
message: format!(
|
||||
"failed to load rollout `{}` for rollback replay: {err}",
|
||||
rollout_path.display()
|
||||
),
|
||||
codex_error_info: Some(CodexErrorInfo::ThreadRollbackFailed),
|
||||
}),
|
||||
})
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Replace with the raw items. We don't want to replace with a normalized
|
||||
// version of the history.
|
||||
sess.replace_history(history.raw_items().to_vec(), None)
|
||||
let rollback_event = ThreadRolledBackEvent { num_turns };
|
||||
let replay_items = initial_history
|
||||
.get_rollout_items()
|
||||
.into_iter()
|
||||
.chain(std::iter::once(RolloutItem::EventMsg(
|
||||
EventMsg::ThreadRolledBack(rollback_event.clone()),
|
||||
)))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let reconstructed = sess
|
||||
.reconstruct_history_from_rollout(turn_context.as_ref(), replay_items.as_slice())
|
||||
.await;
|
||||
sess.replace_history(
|
||||
reconstructed.history,
|
||||
reconstructed.reference_context_item.clone(),
|
||||
)
|
||||
.await;
|
||||
sess.set_previous_turn_settings(reconstructed.previous_turn_settings)
|
||||
.await;
|
||||
sess.recompute_token_usage(turn_context.as_ref()).await;
|
||||
|
||||
sess.send_event_raw_flushed(Event {
|
||||
id: turn_context.sub_id.clone(),
|
||||
msg: EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns }),
|
||||
msg: EventMsg::ThreadRolledBack(rollback_event),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -4869,6 +4945,7 @@ async fn spawn_review_thread(
|
||||
|
||||
let review_turn_context = TurnContext {
|
||||
sub_id: review_turn_id,
|
||||
trace_id: current_span_trace_id(),
|
||||
realtime_active: parent_turn_context.realtime_active,
|
||||
config: per_turn_config,
|
||||
auth_manager: auth_manager_for_context,
|
||||
@@ -5032,25 +5109,38 @@ pub(crate) async fn run_turn(
|
||||
sess.record_context_updates_and_set_reference_context_item(turn_context.as_ref())
|
||||
.await;
|
||||
|
||||
let available_connectors = if turn_context.config.features.enabled(Feature::Apps) {
|
||||
let mcp_tools = match sess
|
||||
.services
|
||||
.mcp_connection_manager
|
||||
.read()
|
||||
.await
|
||||
.list_all_tools()
|
||||
.or_cancel(&cancellation_token)
|
||||
.await
|
||||
{
|
||||
Ok(mcp_tools) => mcp_tools,
|
||||
Err(_) => return None,
|
||||
let loaded_plugins = sess
|
||||
.services
|
||||
.plugins_manager
|
||||
.plugins_for_config(&turn_context.config);
|
||||
// Plain-text @plugin mentions are resolved from the current session's
|
||||
// enabled plugins, then converted into turn-scoped guidance below.
|
||||
let mentioned_plugins =
|
||||
collect_explicit_plugin_mentions(&input, loaded_plugins.capability_summaries());
|
||||
let mcp_tools =
|
||||
if turn_context.config.features.enabled(Feature::Apps) || !mentioned_plugins.is_empty() {
|
||||
// Plugin mentions need raw MCP/app inventory even when app tools
|
||||
// are normally hidden so we can describe the plugin's currently
|
||||
// usable capabilities for this turn.
|
||||
match sess
|
||||
.services
|
||||
.mcp_connection_manager
|
||||
.read()
|
||||
.await
|
||||
.list_all_tools()
|
||||
.or_cancel(&cancellation_token)
|
||||
.await
|
||||
{
|
||||
Ok(mcp_tools) => mcp_tools,
|
||||
Err(_) if turn_context.config.features.enabled(Feature::Apps) => return None,
|
||||
Err(_) => HashMap::new(),
|
||||
}
|
||||
} else {
|
||||
HashMap::new()
|
||||
};
|
||||
let plugin_apps = sess
|
||||
.services
|
||||
.plugins_manager
|
||||
.plugins_for_config(&turn_context.config);
|
||||
let available_connectors = if turn_context.config.features.enabled(Feature::Apps) {
|
||||
let connectors = connectors::merge_plugin_apps_with_accessible(
|
||||
plugin_apps.effective_apps(),
|
||||
loaded_plugins.effective_apps(),
|
||||
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
|
||||
);
|
||||
connectors::with_app_enabled_state(connectors, &turn_context.config)
|
||||
@@ -5111,12 +5201,29 @@ pub(crate) async fn run_turn(
|
||||
.await;
|
||||
}
|
||||
|
||||
let plugin_items =
|
||||
build_plugin_injections(&mentioned_plugins, &mcp_tools, &available_connectors);
|
||||
|
||||
let mut explicitly_enabled_connectors = collect_explicit_app_ids(&input);
|
||||
explicitly_enabled_connectors.extend(collect_explicit_app_ids_from_skill_items(
|
||||
&skill_items,
|
||||
&available_connectors,
|
||||
&skill_name_counts_lower,
|
||||
));
|
||||
// Explicit @plugin mentions can make a plugin's enabled apps callable for
|
||||
// this turn without persisting those connectors as sticky user selections.
|
||||
let mut turn_enabled_connectors = explicitly_enabled_connectors.clone();
|
||||
turn_enabled_connectors.extend(
|
||||
mentioned_plugins
|
||||
.iter()
|
||||
.flat_map(|plugin| plugin.app_connector_ids.iter())
|
||||
.map(|connector_id| connector_id.0.clone())
|
||||
.filter(|connector_id| {
|
||||
available_connectors
|
||||
.iter()
|
||||
.any(|connector| connector.is_enabled && connector.id == *connector_id)
|
||||
}),
|
||||
);
|
||||
let connector_names_by_id = available_connectors
|
||||
.iter()
|
||||
.map(|connector| (connector.id.as_str(), connector.name.as_str()))
|
||||
@@ -5154,6 +5261,10 @@ pub(crate) async fn run_turn(
|
||||
sess.record_conversation_items(&turn_context, &skill_items)
|
||||
.await;
|
||||
}
|
||||
if !plugin_items.is_empty() {
|
||||
sess.record_conversation_items(&turn_context, &plugin_items)
|
||||
.await;
|
||||
}
|
||||
|
||||
sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token())
|
||||
.await;
|
||||
@@ -5222,7 +5333,7 @@ pub(crate) async fn run_turn(
|
||||
&mut client_session,
|
||||
turn_metadata_header.as_deref(),
|
||||
sampling_request_input,
|
||||
&explicitly_enabled_connectors,
|
||||
&turn_enabled_connectors,
|
||||
skills_outcome,
|
||||
&mut server_model_warning_emitted_for_turn,
|
||||
cancellation_token.child_token(),
|
||||
@@ -5768,17 +5879,17 @@ async fn built_tools(
|
||||
.or_cancel(cancellation_token)
|
||||
.await?;
|
||||
drop(mcp_connection_manager);
|
||||
let loaded_plugins = sess
|
||||
.services
|
||||
.plugins_manager
|
||||
.plugins_for_config(&turn_context.config);
|
||||
|
||||
let mut effective_explicitly_enabled_connectors = explicitly_enabled_connectors.clone();
|
||||
effective_explicitly_enabled_connectors.extend(sess.get_connector_selection().await);
|
||||
|
||||
let connectors = if turn_context.features.enabled(Feature::Apps) {
|
||||
let plugin_apps = sess
|
||||
.services
|
||||
.plugins_manager
|
||||
.plugins_for_config(&turn_context.config);
|
||||
let connectors = connectors::merge_plugin_apps_with_accessible(
|
||||
plugin_apps.effective_apps(),
|
||||
loaded_plugins.effective_apps(),
|
||||
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
|
||||
);
|
||||
Some(connectors::with_app_enabled_state(
|
||||
@@ -5789,6 +5900,8 @@ async fn built_tools(
|
||||
None
|
||||
};
|
||||
|
||||
// Keep the connector-grouped app view around for the router even though
|
||||
// app tools only become prompt-visible after explicit selection/discovery.
|
||||
let app_tools = connectors.as_ref().map(|connectors| {
|
||||
filter_codex_apps_mcp_tools(&mcp_tools, connectors, &turn_context.config)
|
||||
});
|
||||
@@ -6748,6 +6861,7 @@ mod tests {
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::models::FunctionCallOutputBody;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use tracing::Span;
|
||||
|
||||
use crate::protocol::CompactedItem;
|
||||
use crate::protocol::CreditsSnapshot;
|
||||
@@ -6825,6 +6939,18 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn assistant_message(text: &str) -> ResponseItem {
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentItem::OutputText {
|
||||
text: text.to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn skill_message(text: &str) -> ResponseItem {
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
@@ -6868,6 +6994,7 @@ mod tests {
|
||||
install_url: None,
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6955,6 +7082,7 @@ mod tests {
|
||||
},
|
||||
connector_id: connector_id.map(str::to_string),
|
||||
connector_name: connector_name.map(str::to_string),
|
||||
plugin_display_names: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7685,6 +7813,7 @@ mod tests {
|
||||
let previous_model = "forked-rollout-model";
|
||||
let previous_context_item = TurnContextItem {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
trace_id: turn_context.trace_id.clone(),
|
||||
cwd: turn_context.cwd.clone(),
|
||||
current_date: turn_context.current_date.clone(),
|
||||
timezone: turn_context.timezone.clone(),
|
||||
@@ -7747,59 +7876,37 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn thread_rollback_drops_last_turn_from_history() {
|
||||
let (sess, tc, rx) = make_session_and_context_with_rx().await;
|
||||
let rollout_path = attach_rollout_recorder(&sess).await;
|
||||
|
||||
let initial_context = sess.build_initial_context(tc.as_ref()).await;
|
||||
sess.record_into_history(&initial_context, tc.as_ref())
|
||||
.await;
|
||||
|
||||
let turn_1 = vec![
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "turn 1 user".to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
},
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentItem::OutputText {
|
||||
text: "turn 1 assistant".to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
},
|
||||
user_message("turn 1 user"),
|
||||
assistant_message("turn 1 assistant"),
|
||||
];
|
||||
sess.record_into_history(&turn_1, tc.as_ref()).await;
|
||||
|
||||
let turn_2 = vec![
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "turn 2 user".to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
},
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentItem::OutputText {
|
||||
text: "turn 2 assistant".to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
},
|
||||
user_message("turn 2 user"),
|
||||
assistant_message("turn 2 assistant"),
|
||||
];
|
||||
sess.record_into_history(&turn_2, tc.as_ref()).await;
|
||||
let mut full_history = Vec::new();
|
||||
full_history.extend(initial_context.clone());
|
||||
full_history.extend(turn_1.clone());
|
||||
full_history.extend(turn_2);
|
||||
sess.replace_history(full_history.clone(), Some(tc.to_turn_context_item()))
|
||||
.await;
|
||||
let rollout_items: Vec<RolloutItem> = full_history
|
||||
.into_iter()
|
||||
.map(RolloutItem::ResponseItem)
|
||||
.collect();
|
||||
sess.persist_rollout_items(&rollout_items).await;
|
||||
sess.set_previous_turn_settings(Some(PreviousTurnSettings {
|
||||
model: "previous-regular-model".to_string(),
|
||||
model: "stale-model".to_string(),
|
||||
realtime_active: Some(tc.realtime_active),
|
||||
}))
|
||||
.await;
|
||||
{
|
||||
let mut state = sess.state.lock().await;
|
||||
state.set_reference_context_item(Some(tc.to_turn_context_item()));
|
||||
}
|
||||
|
||||
handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await;
|
||||
|
||||
@@ -7812,33 +7919,41 @@ mod tests {
|
||||
|
||||
let history = sess.clone_history().await;
|
||||
assert_eq!(expected, history.raw_items());
|
||||
assert_eq!(
|
||||
sess.previous_turn_settings().await,
|
||||
Some(PreviousTurnSettings {
|
||||
model: "previous-regular-model".to_string(),
|
||||
realtime_active: Some(tc.realtime_active),
|
||||
})
|
||||
);
|
||||
assert_eq!(sess.previous_turn_settings().await, None);
|
||||
assert!(sess.reference_context_item().await.is_none());
|
||||
|
||||
let InitialHistory::Resumed(resumed) = RolloutRecorder::get_rollout_history(&rollout_path)
|
||||
.await
|
||||
.expect("read rollout history")
|
||||
else {
|
||||
panic!("expected resumed rollout history");
|
||||
};
|
||||
assert!(resumed.history.iter().any(|item| {
|
||||
matches!(
|
||||
item,
|
||||
RolloutItem::EventMsg(EventMsg::ThreadRolledBack(rollback))
|
||||
if rollback.num_turns == 1
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_rollback_clears_history_when_num_turns_exceeds_existing_turns() {
|
||||
let (sess, tc, rx) = make_session_and_context_with_rx().await;
|
||||
attach_rollout_recorder(&sess).await;
|
||||
|
||||
let initial_context = sess.build_initial_context(tc.as_ref()).await;
|
||||
sess.record_into_history(&initial_context, tc.as_ref())
|
||||
let turn_1 = vec![user_message("turn 1 user")];
|
||||
let mut full_history = Vec::new();
|
||||
full_history.extend(initial_context.clone());
|
||||
full_history.extend(turn_1);
|
||||
sess.replace_history(full_history.clone(), Some(tc.to_turn_context_item()))
|
||||
.await;
|
||||
|
||||
let turn_1 = vec![ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "turn 1 user".to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
}];
|
||||
sess.record_into_history(&turn_1, tc.as_ref()).await;
|
||||
let rollout_items: Vec<RolloutItem> = full_history
|
||||
.into_iter()
|
||||
.map(RolloutItem::ResponseItem)
|
||||
.collect();
|
||||
sess.persist_rollout_items(&rollout_items).await;
|
||||
|
||||
handlers::thread_rollback(&sess, "sub-1".to_string(), 99).await;
|
||||
|
||||
@@ -7849,6 +7964,230 @@ mod tests {
|
||||
assert_eq!(initial_context, history.raw_items());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_rollback_fails_without_persisted_rollout_path() {
|
||||
let (sess, tc, rx) = make_session_and_context_with_rx().await;
|
||||
|
||||
let initial_context = sess.build_initial_context(tc.as_ref()).await;
|
||||
sess.record_into_history(&initial_context, tc.as_ref())
|
||||
.await;
|
||||
|
||||
handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await;
|
||||
|
||||
let error_event = wait_for_thread_rollback_failed(&rx).await;
|
||||
assert_eq!(
|
||||
error_event.message,
|
||||
"thread rollback requires a persisted rollout path"
|
||||
);
|
||||
assert_eq!(
|
||||
error_event.codex_error_info,
|
||||
Some(CodexErrorInfo::ThreadRollbackFailed)
|
||||
);
|
||||
assert_eq!(sess.clone_history().await.raw_items(), initial_context);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context_from_replay() {
|
||||
let (sess, tc, rx) = make_session_and_context_with_rx().await;
|
||||
attach_rollout_recorder(&sess).await;
|
||||
|
||||
let first_context_item = tc.to_turn_context_item();
|
||||
let first_turn_id = first_context_item
|
||||
.turn_id
|
||||
.clone()
|
||||
.expect("turn context should have turn_id");
|
||||
let mut rolled_back_context_item = first_context_item.clone();
|
||||
rolled_back_context_item.turn_id = Some("rolled-back-turn".to_string());
|
||||
rolled_back_context_item.model = "rolled-back-model".to_string();
|
||||
let rolled_back_turn_id = rolled_back_context_item
|
||||
.turn_id
|
||||
.clone()
|
||||
.expect("turn context should have turn_id");
|
||||
let turn_one_user = user_message("turn 1 user");
|
||||
let turn_one_assistant = assistant_message("turn 1 assistant");
|
||||
let turn_two_user = user_message("turn 2 user");
|
||||
let turn_two_assistant = assistant_message("turn 2 assistant");
|
||||
|
||||
sess.persist_rollout_items(&[
|
||||
RolloutItem::EventMsg(EventMsg::TurnStarted(
|
||||
codex_protocol::protocol::TurnStartedEvent {
|
||||
turn_id: first_turn_id.clone(),
|
||||
model_context_window: Some(128_000),
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
},
|
||||
)),
|
||||
RolloutItem::EventMsg(EventMsg::UserMessage(
|
||||
codex_protocol::protocol::UserMessageEvent {
|
||||
message: "turn 1 user".to_string(),
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
},
|
||||
)),
|
||||
RolloutItem::TurnContext(first_context_item.clone()),
|
||||
RolloutItem::ResponseItem(turn_one_user.clone()),
|
||||
RolloutItem::ResponseItem(turn_one_assistant.clone()),
|
||||
RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
turn_id: first_turn_id,
|
||||
last_agent_message: None,
|
||||
})),
|
||||
RolloutItem::EventMsg(EventMsg::TurnStarted(
|
||||
codex_protocol::protocol::TurnStartedEvent {
|
||||
turn_id: rolled_back_turn_id.clone(),
|
||||
model_context_window: Some(128_000),
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
},
|
||||
)),
|
||||
RolloutItem::EventMsg(EventMsg::UserMessage(
|
||||
codex_protocol::protocol::UserMessageEvent {
|
||||
message: "turn 2 user".to_string(),
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
},
|
||||
)),
|
||||
RolloutItem::TurnContext(rolled_back_context_item),
|
||||
RolloutItem::ResponseItem(turn_two_user),
|
||||
RolloutItem::ResponseItem(turn_two_assistant),
|
||||
RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
turn_id: rolled_back_turn_id,
|
||||
last_agent_message: None,
|
||||
})),
|
||||
])
|
||||
.await;
|
||||
sess.replace_history(
|
||||
vec![assistant_message("stale history")],
|
||||
Some(first_context_item.clone()),
|
||||
)
|
||||
.await;
|
||||
sess.set_previous_turn_settings(Some(PreviousTurnSettings {
|
||||
model: "stale-model".to_string(),
|
||||
realtime_active: None,
|
||||
}))
|
||||
.await;
|
||||
|
||||
handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await;
|
||||
let rollback_event = wait_for_thread_rolled_back(&rx).await;
|
||||
assert_eq!(rollback_event.num_turns, 1);
|
||||
|
||||
assert_eq!(
|
||||
sess.clone_history().await.raw_items(),
|
||||
vec![turn_one_user, turn_one_assistant]
|
||||
);
|
||||
assert_eq!(
|
||||
sess.previous_turn_settings().await,
|
||||
Some(PreviousTurnSettings {
|
||||
model: tc.model_info.slug.clone(),
|
||||
realtime_active: Some(tc.realtime_active),
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(sess.reference_context_item().await)
|
||||
.expect("serialize replay reference context item"),
|
||||
serde_json::to_value(Some(first_context_item))
|
||||
.expect("serialize expected reference context item")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_rollback_persists_marker_and_replays_cumulatively() {
|
||||
let (sess, tc, rx) = make_session_and_context_with_rx().await;
|
||||
let rollout_path = attach_rollout_recorder(&sess).await;
|
||||
let turn_context_item = tc.to_turn_context_item();
|
||||
|
||||
sess.persist_rollout_items(&[
|
||||
RolloutItem::EventMsg(EventMsg::TurnStarted(
|
||||
codex_protocol::protocol::TurnStartedEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
model_context_window: Some(128_000),
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
},
|
||||
)),
|
||||
RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent {
|
||||
message: "turn 1 user".to_string(),
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
})),
|
||||
RolloutItem::TurnContext(turn_context_item.clone()),
|
||||
RolloutItem::ResponseItem(user_message("turn 1 user")),
|
||||
RolloutItem::ResponseItem(assistant_message("turn 1 assistant")),
|
||||
RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
last_agent_message: None,
|
||||
})),
|
||||
RolloutItem::EventMsg(EventMsg::TurnStarted(
|
||||
codex_protocol::protocol::TurnStartedEvent {
|
||||
turn_id: "turn-2".to_string(),
|
||||
model_context_window: Some(128_000),
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
},
|
||||
)),
|
||||
RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent {
|
||||
message: "turn 2 user".to_string(),
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
})),
|
||||
RolloutItem::TurnContext(turn_context_item.clone()),
|
||||
RolloutItem::ResponseItem(user_message("turn 2 user")),
|
||||
RolloutItem::ResponseItem(assistant_message("turn 2 assistant")),
|
||||
RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
turn_id: "turn-2".to_string(),
|
||||
last_agent_message: None,
|
||||
})),
|
||||
RolloutItem::EventMsg(EventMsg::TurnStarted(
|
||||
codex_protocol::protocol::TurnStartedEvent {
|
||||
turn_id: "turn-3".to_string(),
|
||||
model_context_window: Some(128_000),
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
},
|
||||
)),
|
||||
RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent {
|
||||
message: "turn 3 user".to_string(),
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
})),
|
||||
RolloutItem::TurnContext(turn_context_item),
|
||||
RolloutItem::ResponseItem(user_message("turn 3 user")),
|
||||
RolloutItem::ResponseItem(assistant_message("turn 3 assistant")),
|
||||
RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
turn_id: "turn-3".to_string(),
|
||||
last_agent_message: None,
|
||||
})),
|
||||
])
|
||||
.await;
|
||||
|
||||
handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await;
|
||||
let first_rollback = wait_for_thread_rolled_back(&rx).await;
|
||||
assert_eq!(first_rollback.num_turns, 1);
|
||||
handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await;
|
||||
let second_rollback = wait_for_thread_rolled_back(&rx).await;
|
||||
assert_eq!(second_rollback.num_turns, 1);
|
||||
|
||||
assert_eq!(
|
||||
sess.clone_history().await.raw_items(),
|
||||
vec![
|
||||
user_message("turn 1 user"),
|
||||
assistant_message("turn 1 assistant")
|
||||
]
|
||||
);
|
||||
|
||||
let InitialHistory::Resumed(resumed) = RolloutRecorder::get_rollout_history(&rollout_path)
|
||||
.await
|
||||
.expect("read rollout history")
|
||||
else {
|
||||
panic!("expected resumed rollout history");
|
||||
};
|
||||
let rollback_markers = resumed
|
||||
.history
|
||||
.iter()
|
||||
.filter(|item| matches!(item, RolloutItem::EventMsg(EventMsg::ThreadRolledBack(_))))
|
||||
.count();
|
||||
assert_eq!(rollback_markers, 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_rollback_fails_when_turn_in_progress() {
|
||||
let (sess, tc, rx) = make_session_and_context_with_rx().await;
|
||||
@@ -8265,6 +8604,33 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
async fn attach_rollout_recorder(session: &Arc<Session>) -> PathBuf {
|
||||
let config = session.get_config().await;
|
||||
let recorder = RolloutRecorder::new(
|
||||
config.as_ref(),
|
||||
RolloutRecorderParams::new(
|
||||
ThreadId::default(),
|
||||
None,
|
||||
SessionSource::Exec,
|
||||
BaseInstructions::default(),
|
||||
Vec::new(),
|
||||
EventPersistenceMode::Limited,
|
||||
),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("create rollout recorder");
|
||||
let rollout_path = recorder.rollout_path().to_path_buf();
|
||||
{
|
||||
let mut rollout = session.services.rollout.lock().await;
|
||||
*rollout = Some(recorder);
|
||||
}
|
||||
session.ensure_rollout_materialized().await;
|
||||
session.flush_rollout().await;
|
||||
rollout_path
|
||||
}
|
||||
|
||||
fn text_block(s: &str) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "text",
|
||||
@@ -8659,6 +9025,43 @@ mod tests {
|
||||
assert_eq!(submitted.trace, Some(expected_trace));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn new_default_turn_captures_current_span_trace_id() {
|
||||
let (session, _turn_context) = make_session_and_context().await;
|
||||
|
||||
init_test_tracing();
|
||||
|
||||
let request_parent = W3cTraceContext {
|
||||
traceparent: Some("00-00000000000000000000000000000011-0000000000000022-01".into()),
|
||||
tracestate: Some("vendor=value".into()),
|
||||
};
|
||||
let request_span = info_span!("app_server.request");
|
||||
assert!(set_parent_from_w3c_trace_context(
|
||||
&request_span,
|
||||
&request_parent
|
||||
));
|
||||
|
||||
let turn_context_item = async {
|
||||
let expected_trace_id = Span::current()
|
||||
.context()
|
||||
.span()
|
||||
.span_context()
|
||||
.trace_id()
|
||||
.to_string();
|
||||
let turn_context = session.new_default_turn().await;
|
||||
let turn_context_item = turn_context.to_turn_context_item();
|
||||
assert_eq!(turn_context_item.trace_id, Some(expected_trace_id));
|
||||
turn_context_item
|
||||
}
|
||||
.instrument(request_span)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
turn_context_item.trace_id.as_deref(),
|
||||
Some("00000000000000000000000000000011")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submission_dispatch_span_prefers_submission_trace_context() {
|
||||
init_test_tracing();
|
||||
|
||||
@@ -40,6 +40,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ
|
||||
let previous_model = "previous-rollout-model";
|
||||
let previous_context_item = TurnContextItem {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
trace_id: turn_context.trace_id.clone(),
|
||||
cwd: turn_context.cwd.clone(),
|
||||
current_date: turn_context.current_date.clone(),
|
||||
timezone: turn_context.timezone.clone(),
|
||||
@@ -78,6 +79,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif
|
||||
let previous_model = "previous-rollout-model";
|
||||
let mut previous_context_item = TurnContextItem {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
trace_id: turn_context.trace_id.clone(),
|
||||
cwd: turn_context.cwd.clone(),
|
||||
current_date: turn_context.current_date.clone(),
|
||||
timezone: turn_context.timezone.clone(),
|
||||
@@ -739,6 +741,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
|
||||
let previous_model = "previous-rollout-model";
|
||||
let previous_context_item = TurnContextItem {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
trace_id: turn_context.trace_id.clone(),
|
||||
cwd: turn_context.cwd.clone(),
|
||||
current_date: turn_context.current_date.clone(),
|
||||
timezone: turn_context.timezone.clone(),
|
||||
@@ -810,6 +813,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
|
||||
.expect("serialize seeded reference context item"),
|
||||
serde_json::to_value(Some(TurnContextItem {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
trace_id: turn_context.trace_id.clone(),
|
||||
cwd: turn_context.cwd.clone(),
|
||||
current_date: turn_context.current_date.clone(),
|
||||
timezone: turn_context.timezone.clone(),
|
||||
@@ -838,6 +842,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
|
||||
let previous_model = "previous-rollout-model";
|
||||
let previous_context_item = TurnContextItem {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
trace_id: turn_context.trace_id.clone(),
|
||||
cwd: turn_context.cwd.clone(),
|
||||
current_date: turn_context.current_date.clone(),
|
||||
timezone: turn_context.timezone.clone(),
|
||||
@@ -943,6 +948,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo
|
||||
let unmatched_abort_turn_id = "other-turn".to_string();
|
||||
let current_context_item = TurnContextItem {
|
||||
turn_id: Some(current_turn_id.clone()),
|
||||
trace_id: turn_context.trace_id.clone(),
|
||||
cwd: turn_context.cwd.clone(),
|
||||
current_date: turn_context.current_date.clone(),
|
||||
timezone: turn_context.timezone.clone(),
|
||||
@@ -1044,6 +1050,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea
|
||||
let previous_model = "previous-rollout-model";
|
||||
let previous_context_item = TurnContextItem {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
trace_id: turn_context.trace_id.clone(),
|
||||
cwd: turn_context.cwd.clone(),
|
||||
current_date: turn_context.current_date.clone(),
|
||||
timezone: turn_context.timezone.clone(),
|
||||
@@ -1187,6 +1194,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
|
||||
let previous_model = "previous-rollout-model";
|
||||
let previous_context_item = TurnContextItem {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
trace_id: turn_context.trace_id.clone(),
|
||||
cwd: turn_context.cwd.clone(),
|
||||
current_date: turn_context.current_date.clone(),
|
||||
timezone: turn_context.timezone.clone(),
|
||||
|
||||
@@ -219,10 +219,6 @@ pub struct Config {
|
||||
/// using backend-specific headers or URLs to enforce this.
|
||||
pub enforce_residency: Constrained<Option<ResidencyRequirement>>,
|
||||
|
||||
/// True if the user passed in an override or set a value in config.toml
|
||||
/// for either of approval_policy or sandbox_mode.
|
||||
pub did_user_set_custom_approval_policy_or_sandbox_mode: bool,
|
||||
|
||||
/// When `true`, `AgentReasoning` events emitted by the backend will be
|
||||
/// suppressed from the frontend output. This can reduce visual noise when
|
||||
/// users are only interested in the final agent responses.
|
||||
@@ -1789,9 +1785,6 @@ impl Config {
|
||||
let active_project = cfg
|
||||
.get_active_project(&resolved_cwd)
|
||||
.unwrap_or(ProjectConfig { trust_level: None });
|
||||
let sandbox_mode_was_explicit = sandbox_mode.is_some()
|
||||
|| config_profile.sandbox_mode.is_some()
|
||||
|| cfg.sandbox_mode.is_some();
|
||||
|
||||
let windows_sandbox_level = match windows_sandbox_mode {
|
||||
Some(WindowsSandboxModeToml::Elevated) => WindowsSandboxLevel::Elevated,
|
||||
@@ -1821,9 +1814,6 @@ impl Config {
|
||||
}
|
||||
}
|
||||
}
|
||||
let approval_policy_was_explicit = approval_policy_override.is_some()
|
||||
|| config_profile.approval_policy.is_some()
|
||||
|| cfg.approval_policy.is_some();
|
||||
let mut approval_policy = approval_policy_override
|
||||
.or(config_profile.approval_policy)
|
||||
.or(cfg.approval_policy)
|
||||
@@ -1836,9 +1826,7 @@ impl Config {
|
||||
AskForApproval::default()
|
||||
}
|
||||
});
|
||||
if !approval_policy_was_explicit
|
||||
&& let Err(err) = constrained_approval_policy.can_set(&approval_policy)
|
||||
{
|
||||
if let Err(err) = constrained_approval_policy.can_set(&approval_policy) {
|
||||
tracing::warn!(
|
||||
error = %err,
|
||||
"default approval policy is disallowed by requirements; falling back to required default"
|
||||
@@ -1847,10 +1835,6 @@ impl Config {
|
||||
}
|
||||
let web_search_mode = resolve_web_search_mode(&cfg, &config_profile, &features)
|
||||
.unwrap_or(WebSearchMode::Cached);
|
||||
// TODO(dylan): We should be able to leverage ConfigLayerStack so that
|
||||
// we can reliably check this at every config level.
|
||||
let did_user_set_custom_approval_policy_or_sandbox_mode =
|
||||
approval_policy_was_explicit || sandbox_mode_was_explicit;
|
||||
|
||||
let mut model_providers = built_in_model_providers();
|
||||
// Merge user-defined providers into the built-in list.
|
||||
@@ -2155,7 +2139,6 @@ impl Config {
|
||||
macos_seatbelt_profile_extensions: None,
|
||||
},
|
||||
enforce_residency: enforce_residency.value,
|
||||
did_user_set_custom_approval_policy_or_sandbox_mode,
|
||||
notify: cfg.notify,
|
||||
user_instructions,
|
||||
base_instructions,
|
||||
@@ -2727,13 +2710,11 @@ allowed_domains = ["openai.com"]
|
||||
NetworkToml {
|
||||
enabled: Some(true),
|
||||
proxy_url: Some("http://127.0.0.1:43128".to_string()),
|
||||
admin_url: None,
|
||||
enable_socks5: Some(false),
|
||||
socks_url: None,
|
||||
enable_socks5_udp: None,
|
||||
allow_upstream_proxy: Some(false),
|
||||
dangerously_allow_non_loopback_proxy: None,
|
||||
dangerously_allow_non_loopback_admin: None,
|
||||
dangerously_allow_all_unix_sockets: None,
|
||||
mode: None,
|
||||
allowed_domains: Some(vec!["openai.com".to_string()]),
|
||||
@@ -3489,7 +3470,6 @@ profile = "project"
|
||||
config.permissions.sandbox_policy.get(),
|
||||
&SandboxPolicy::DangerFullAccess
|
||||
));
|
||||
assert!(config.did_user_set_custom_approval_policy_or_sandbox_mode);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -5195,7 +5175,6 @@ model_verbosity = "high"
|
||||
macos_seatbelt_profile_extensions: None,
|
||||
},
|
||||
enforce_residency: Constrained::allow_any(None),
|
||||
did_user_set_custom_approval_policy_or_sandbox_mode: true,
|
||||
user_instructions: None,
|
||||
notify: None,
|
||||
cwd: fixture.cwd(),
|
||||
@@ -5325,7 +5304,6 @@ model_verbosity = "high"
|
||||
macos_seatbelt_profile_extensions: None,
|
||||
},
|
||||
enforce_residency: Constrained::allow_any(None),
|
||||
did_user_set_custom_approval_policy_or_sandbox_mode: true,
|
||||
user_instructions: None,
|
||||
notify: None,
|
||||
cwd: fixture.cwd(),
|
||||
@@ -5453,7 +5431,6 @@ model_verbosity = "high"
|
||||
macos_seatbelt_profile_extensions: None,
|
||||
},
|
||||
enforce_residency: Constrained::allow_any(None),
|
||||
did_user_set_custom_approval_policy_or_sandbox_mode: true,
|
||||
user_instructions: None,
|
||||
notify: None,
|
||||
cwd: fixture.cwd(),
|
||||
@@ -5567,7 +5544,6 @@ model_verbosity = "high"
|
||||
macos_seatbelt_profile_extensions: None,
|
||||
},
|
||||
enforce_residency: Constrained::allow_any(None),
|
||||
did_user_set_custom_approval_policy_or_sandbox_mode: true,
|
||||
user_instructions: None,
|
||||
notify: None,
|
||||
cwd: fixture.cwd(),
|
||||
@@ -5649,24 +5625,6 @@ model_verbosity = "high"
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_did_user_set_custom_approval_policy_or_sandbox_mode_defaults_no() -> anyhow::Result<()>
|
||||
{
|
||||
let fixture = create_test_fixture()?;
|
||||
|
||||
let config = Config::load_from_base_config_with_overrides(
|
||||
fixture.cfg.clone(),
|
||||
ConfigOverrides {
|
||||
..Default::default()
|
||||
},
|
||||
fixture.codex_home(),
|
||||
)?;
|
||||
|
||||
assert!(config.did_user_set_custom_approval_policy_or_sandbox_mode);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() -> anyhow::Result<()>
|
||||
{
|
||||
|
||||
@@ -183,14 +183,6 @@ impl NetworkProxySpec {
|
||||
constraints.dangerously_allow_non_loopback_proxy =
|
||||
Some(dangerously_allow_non_loopback_proxy);
|
||||
}
|
||||
if let Some(dangerously_allow_non_loopback_admin) =
|
||||
requirements.dangerously_allow_non_loopback_admin
|
||||
{
|
||||
config.network.dangerously_allow_non_loopback_admin =
|
||||
dangerously_allow_non_loopback_admin;
|
||||
constraints.dangerously_allow_non_loopback_admin =
|
||||
Some(dangerously_allow_non_loopback_admin);
|
||||
}
|
||||
if let Some(dangerously_allow_all_unix_sockets) =
|
||||
requirements.dangerously_allow_all_unix_sockets
|
||||
{
|
||||
|
||||
@@ -17,13 +17,11 @@ pub struct PermissionsToml {
|
||||
pub struct NetworkToml {
|
||||
pub enabled: Option<bool>,
|
||||
pub proxy_url: Option<String>,
|
||||
pub admin_url: Option<String>,
|
||||
pub enable_socks5: Option<bool>,
|
||||
pub socks_url: Option<String>,
|
||||
pub enable_socks5_udp: Option<bool>,
|
||||
pub allow_upstream_proxy: Option<bool>,
|
||||
pub dangerously_allow_non_loopback_proxy: Option<bool>,
|
||||
pub dangerously_allow_non_loopback_admin: Option<bool>,
|
||||
pub dangerously_allow_all_unix_sockets: Option<bool>,
|
||||
#[schemars(with = "Option<NetworkModeSchema>")]
|
||||
pub mode: Option<NetworkMode>,
|
||||
@@ -48,9 +46,6 @@ impl NetworkToml {
|
||||
if let Some(proxy_url) = self.proxy_url.as_ref() {
|
||||
config.network.proxy_url = proxy_url.clone();
|
||||
}
|
||||
if let Some(admin_url) = self.admin_url.as_ref() {
|
||||
config.network.admin_url = admin_url.clone();
|
||||
}
|
||||
if let Some(enable_socks5) = self.enable_socks5 {
|
||||
config.network.enable_socks5 = enable_socks5;
|
||||
}
|
||||
@@ -69,12 +64,6 @@ impl NetworkToml {
|
||||
config.network.dangerously_allow_non_loopback_proxy =
|
||||
dangerously_allow_non_loopback_proxy;
|
||||
}
|
||||
if let Some(dangerously_allow_non_loopback_admin) =
|
||||
self.dangerously_allow_non_loopback_admin
|
||||
{
|
||||
config.network.dangerously_allow_non_loopback_admin =
|
||||
dangerously_allow_non_loopback_admin;
|
||||
}
|
||||
if let Some(dangerously_allow_all_unix_sockets) = self.dangerously_allow_all_unix_sockets {
|
||||
config.network.dangerously_allow_all_unix_sockets = dangerously_allow_all_unix_sockets;
|
||||
}
|
||||
|
||||
+204
-25
@@ -1,7 +1,9 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::LazyLock;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
use std::time::Duration;
|
||||
@@ -26,11 +28,14 @@ use crate::default_client::is_first_party_chat_originator;
|
||||
use crate::default_client::originator;
|
||||
use crate::features::Feature;
|
||||
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use crate::mcp::McpManager;
|
||||
use crate::mcp::ToolPluginProvenance;
|
||||
use crate::mcp::auth::compute_auth_statuses;
|
||||
use crate::mcp::with_codex_apps_mcp;
|
||||
use crate::mcp_connection_manager::McpConnectionManager;
|
||||
use crate::mcp_connection_manager::codex_apps_tools_cache_key;
|
||||
use crate::plugins::AppConnectorId;
|
||||
use crate::plugins::PluginsManager;
|
||||
use crate::token_data::TokenData;
|
||||
|
||||
pub const CONNECTORS_CACHE_TTL: Duration = Duration::from_secs(3600);
|
||||
@@ -123,9 +128,12 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status(
|
||||
let auth_manager = auth_manager_from_config(config);
|
||||
let auth = auth_manager.auth().await;
|
||||
let cache_key = accessible_connectors_cache_key(config, auth.as_ref());
|
||||
let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(config.codex_home.clone())));
|
||||
let tool_plugin_provenance = mcp_manager.tool_plugin_provenance(config);
|
||||
if !force_refetch && let Some(cached_connectors) = read_cached_accessible_connectors(&cache_key)
|
||||
{
|
||||
let cached_connectors = filter_disallowed_connectors(cached_connectors);
|
||||
let cached_connectors = with_app_plugin_sources(cached_connectors, &tool_plugin_provenance);
|
||||
return Ok(AccessibleConnectorsStatus {
|
||||
connectors: cached_connectors,
|
||||
codex_apps_ready: true,
|
||||
@@ -162,6 +170,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status(
|
||||
sandbox_state,
|
||||
config.codex_home.clone(),
|
||||
codex_apps_tools_cache_key(auth.as_ref()),
|
||||
ToolPluginProvenance::default(),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -210,6 +219,8 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status(
|
||||
if codex_apps_ready || !accessible_connectors.is_empty() {
|
||||
write_cached_accessible_connectors(cache_key, &accessible_connectors);
|
||||
}
|
||||
let accessible_connectors =
|
||||
with_app_plugin_sources(accessible_connectors, &tool_plugin_provenance);
|
||||
Ok(AccessibleConnectorsStatus {
|
||||
connectors: accessible_connectors,
|
||||
codex_apps_ready,
|
||||
@@ -291,13 +302,19 @@ pub fn connector_mention_slug(connector: &AppInfo) -> String {
|
||||
pub(crate) fn accessible_connectors_from_mcp_tools(
|
||||
mcp_tools: &HashMap<String, crate::mcp_connection_manager::ToolInfo>,
|
||||
) -> Vec<AppInfo> {
|
||||
// ToolInfo already carries plugin provenance, so app-level plugin sources
|
||||
// can be derived here instead of requiring a separate enrichment pass.
|
||||
let tools = mcp_tools.values().filter_map(|tool| {
|
||||
if tool.server_name != CODEX_APPS_MCP_SERVER_NAME {
|
||||
return None;
|
||||
}
|
||||
let connector_id = tool.connector_id.as_deref()?;
|
||||
let connector_name = normalize_connector_value(tool.connector_name.as_deref());
|
||||
Some((connector_id.to_string(), connector_name))
|
||||
Some((
|
||||
connector_id.to_string(),
|
||||
connector_name,
|
||||
tool.plugin_display_names.clone(),
|
||||
))
|
||||
});
|
||||
collect_accessible_connectors(tools)
|
||||
}
|
||||
@@ -334,6 +351,9 @@ pub fn merge_connectors(
|
||||
if existing.distribution_channel.is_none() && connector.distribution_channel.is_some() {
|
||||
existing.distribution_channel = connector.distribution_channel;
|
||||
}
|
||||
existing
|
||||
.plugin_display_names
|
||||
.extend(connector.plugin_display_names);
|
||||
} else {
|
||||
merged.insert(connector_id, connector);
|
||||
}
|
||||
@@ -344,6 +364,8 @@ pub fn merge_connectors(
|
||||
if connector.install_url.is_none() {
|
||||
connector.install_url = Some(connector_install_url(&connector.name, &connector.id));
|
||||
}
|
||||
connector.plugin_display_names.sort_unstable();
|
||||
connector.plugin_display_names.dedup();
|
||||
}
|
||||
merged.sort_by(|left, right| {
|
||||
right
|
||||
@@ -407,6 +429,18 @@ pub fn with_app_enabled_state(mut connectors: Vec<AppInfo>, config: &Config) ->
|
||||
connectors
|
||||
}
|
||||
|
||||
pub fn with_app_plugin_sources(
|
||||
mut connectors: Vec<AppInfo>,
|
||||
tool_plugin_provenance: &ToolPluginProvenance,
|
||||
) -> Vec<AppInfo> {
|
||||
for connector in &mut connectors {
|
||||
connector.plugin_display_names = tool_plugin_provenance
|
||||
.plugin_display_names_for_connector_id(connector.id.as_str())
|
||||
.to_vec();
|
||||
}
|
||||
connectors
|
||||
}
|
||||
|
||||
pub(crate) fn app_tool_policy(
|
||||
config: &Config,
|
||||
connector_id: Option<&str>,
|
||||
@@ -579,35 +613,49 @@ fn app_tool_policy_from_apps_config(
|
||||
|
||||
fn collect_accessible_connectors<I>(tools: I) -> Vec<AppInfo>
|
||||
where
|
||||
I: IntoIterator<Item = (String, Option<String>)>,
|
||||
I: IntoIterator<Item = (String, Option<String>, Vec<String>)>,
|
||||
{
|
||||
let mut connectors: HashMap<String, String> = HashMap::new();
|
||||
for (connector_id, connector_name) in tools {
|
||||
let mut connectors: HashMap<String, (String, BTreeSet<String>)> = HashMap::new();
|
||||
for (connector_id, connector_name, plugin_display_names) in tools {
|
||||
let connector_name = connector_name.unwrap_or_else(|| connector_id.clone());
|
||||
if let Some(existing_name) = connectors.get_mut(&connector_id) {
|
||||
if let Some((existing_name, existing_plugin_display_names)) =
|
||||
connectors.get_mut(&connector_id)
|
||||
{
|
||||
if existing_name == &connector_id && connector_name != connector_id {
|
||||
*existing_name = connector_name;
|
||||
}
|
||||
existing_plugin_display_names.extend(plugin_display_names);
|
||||
} else {
|
||||
connectors.insert(connector_id, connector_name);
|
||||
connectors.insert(
|
||||
connector_id,
|
||||
(
|
||||
connector_name,
|
||||
plugin_display_names
|
||||
.into_iter()
|
||||
.collect::<BTreeSet<String>>(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut accessible: Vec<AppInfo> = connectors
|
||||
.into_iter()
|
||||
.map(|(connector_id, connector_name)| AppInfo {
|
||||
id: connector_id.clone(),
|
||||
name: connector_name.clone(),
|
||||
description: None,
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
branding: None,
|
||||
app_metadata: None,
|
||||
labels: None,
|
||||
install_url: Some(connector_install_url(&connector_name, &connector_id)),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
})
|
||||
.map(
|
||||
|(connector_id, (connector_name, plugin_display_names))| AppInfo {
|
||||
id: connector_id.clone(),
|
||||
name: connector_name.clone(),
|
||||
description: None,
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
branding: None,
|
||||
app_metadata: None,
|
||||
labels: None,
|
||||
install_url: Some(connector_install_url(&connector_name, &connector_id)),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
plugin_display_names: plugin_display_names.into_iter().collect(),
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
accessible.sort_by(|left, right| {
|
||||
right
|
||||
@@ -638,6 +686,7 @@ fn plugin_app_to_app_info(connector_id: AppConnectorId) -> AppInfo {
|
||||
install_url: Some(connector_install_url(&name, &connector_id)),
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -681,7 +730,11 @@ mod tests {
|
||||
use crate::config::types::AppToolConfig;
|
||||
use crate::config::types::AppToolsConfig;
|
||||
use crate::config::types::AppsDefaultConfig;
|
||||
use crate::mcp_connection_manager::ToolInfo;
|
||||
use pretty_assertions::assert_eq;
|
||||
use rmcp::model::JsonObject;
|
||||
use rmcp::model::Tool;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn annotations(
|
||||
destructive_hint: Option<bool>,
|
||||
@@ -710,13 +763,30 @@ mod tests {
|
||||
labels: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_connectors_replaces_plugin_placeholder_name_with_accessible_name() {
|
||||
let plugin = plugin_app_to_app_info(AppConnectorId("calendar".to_string()));
|
||||
let accessible = AppInfo {
|
||||
fn plugin_names(names: &[&str]) -> Vec<String> {
|
||||
names.iter().map(ToString::to_string).collect()
|
||||
}
|
||||
|
||||
fn test_tool_definition(tool_name: &str) -> Tool {
|
||||
Tool {
|
||||
name: tool_name.to_string().into(),
|
||||
title: None,
|
||||
description: None,
|
||||
input_schema: Arc::new(JsonObject::default()),
|
||||
output_schema: None,
|
||||
annotations: None,
|
||||
execution: None,
|
||||
icons: None,
|
||||
meta: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn google_calendar_accessible_connector(plugin_display_names: &[&str]) -> AppInfo {
|
||||
AppInfo {
|
||||
id: "calendar".to_string(),
|
||||
name: "Google Calendar".to_string(),
|
||||
description: Some("Plan events".to_string()),
|
||||
@@ -729,7 +799,30 @@ mod tests {
|
||||
install_url: None,
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
};
|
||||
plugin_display_names: plugin_names(plugin_display_names),
|
||||
}
|
||||
}
|
||||
|
||||
fn codex_app_tool(
|
||||
tool_name: &str,
|
||||
connector_id: &str,
|
||||
connector_name: Option<&str>,
|
||||
plugin_display_names: &[&str],
|
||||
) -> ToolInfo {
|
||||
ToolInfo {
|
||||
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
||||
tool_name: tool_name.to_string(),
|
||||
tool: test_tool_definition(tool_name),
|
||||
connector_id: Some(connector_id.to_string()),
|
||||
connector_name: connector_name.map(ToOwned::to_owned),
|
||||
plugin_display_names: plugin_names(plugin_display_names),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_connectors_replaces_plugin_placeholder_name_with_accessible_name() {
|
||||
let plugin = plugin_app_to_app_info(AppConnectorId("calendar".to_string()));
|
||||
let accessible = google_calendar_accessible_connector(&[]);
|
||||
|
||||
let merged = merge_connectors(vec![plugin], vec![accessible]);
|
||||
|
||||
@@ -748,11 +841,97 @@ mod tests {
|
||||
install_url: Some(connector_install_url("calendar", "calendar")),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}]
|
||||
);
|
||||
assert_eq!(connector_mention_slug(&merged[0]), "google-calendar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessible_connectors_from_mcp_tools_carries_plugin_display_names() {
|
||||
let tools = HashMap::from([
|
||||
(
|
||||
"mcp__codex_apps__calendar_list_events".to_string(),
|
||||
codex_app_tool(
|
||||
"calendar_list_events",
|
||||
"calendar",
|
||||
None,
|
||||
&["sample", "sample"],
|
||||
),
|
||||
),
|
||||
(
|
||||
"mcp__codex_apps__calendar_create_event".to_string(),
|
||||
codex_app_tool(
|
||||
"calendar_create_event",
|
||||
"calendar",
|
||||
Some("Google Calendar"),
|
||||
&["beta", "sample"],
|
||||
),
|
||||
),
|
||||
(
|
||||
"mcp__sample__echo".to_string(),
|
||||
ToolInfo {
|
||||
server_name: "sample".to_string(),
|
||||
tool_name: "echo".to_string(),
|
||||
tool: test_tool_definition("echo"),
|
||||
connector_id: None,
|
||||
connector_name: None,
|
||||
plugin_display_names: plugin_names(&["ignored"]),
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
let connectors = accessible_connectors_from_mcp_tools(&tools);
|
||||
|
||||
assert_eq!(
|
||||
connectors,
|
||||
vec![AppInfo {
|
||||
id: "calendar".to_string(),
|
||||
name: "Google Calendar".to_string(),
|
||||
description: None,
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
install_url: Some(connector_install_url("Google Calendar", "calendar")),
|
||||
branding: None,
|
||||
app_metadata: None,
|
||||
labels: None,
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
plugin_display_names: plugin_names(&["beta", "sample"]),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_connectors_unions_and_dedupes_plugin_display_names() {
|
||||
let mut plugin = plugin_app_to_app_info(AppConnectorId("calendar".to_string()));
|
||||
plugin.plugin_display_names = plugin_names(&["sample", "alpha", "sample"]);
|
||||
|
||||
let accessible = google_calendar_accessible_connector(&["beta", "alpha"]);
|
||||
|
||||
let merged = merge_connectors(vec![plugin], vec![accessible]);
|
||||
|
||||
assert_eq!(
|
||||
merged,
|
||||
vec![AppInfo {
|
||||
id: "calendar".to_string(),
|
||||
name: "Google Calendar".to_string(),
|
||||
description: Some("Plan events".to_string()),
|
||||
logo_url: Some("https://example.com/logo.png".to_string()),
|
||||
logo_url_dark: Some("https://example.com/logo-dark.png".to_string()),
|
||||
distribution_channel: Some("workspace".to_string()),
|
||||
branding: None,
|
||||
app_metadata: None,
|
||||
labels: None,
|
||||
install_url: Some(connector_install_url("calendar", "calendar")),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
plugin_display_names: plugin_names(&["alpha", "beta", "sample"]),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_tool_policy_uses_global_defaults_for_destructive_hints() {
|
||||
let apps_config = AppsConfigToml {
|
||||
|
||||
@@ -13,6 +13,7 @@ pub mod auth;
|
||||
mod client;
|
||||
mod client_common;
|
||||
pub mod codex;
|
||||
mod realtime_context;
|
||||
mod realtime_conversation;
|
||||
pub use codex::SteerInputError;
|
||||
mod codex_thread;
|
||||
|
||||
@@ -26,6 +26,7 @@ use crate::mcp::auth::compute_auth_statuses;
|
||||
use crate::mcp_connection_manager::McpConnectionManager;
|
||||
use crate::mcp_connection_manager::SandboxState;
|
||||
use crate::mcp_connection_manager::codex_apps_tools_cache_key;
|
||||
use crate::plugins::PluginCapabilitySummary;
|
||||
use crate::plugins::PluginsManager;
|
||||
|
||||
const MCP_TOOL_NAME_PREFIX: &str = "mcp";
|
||||
@@ -35,6 +36,64 @@ const CODEX_CONNECTORS_TOKEN_ENV_VAR: &str = "CODEX_CONNECTORS_TOKEN";
|
||||
const OPENAI_CONNECTORS_MCP_BASE_URL: &str = "https://api.openai.com";
|
||||
const OPENAI_CONNECTORS_MCP_PATH: &str = "/v1/connectors/gateways/flat/mcp";
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ToolPluginProvenance {
|
||||
plugin_display_names_by_connector_id: HashMap<String, Vec<String>>,
|
||||
plugin_display_names_by_mcp_server_name: HashMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
impl ToolPluginProvenance {
|
||||
pub fn plugin_display_names_for_connector_id(&self, connector_id: &str) -> &[String] {
|
||||
self.plugin_display_names_by_connector_id
|
||||
.get(connector_id)
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or(&[])
|
||||
}
|
||||
|
||||
pub fn plugin_display_names_for_mcp_server_name(&self, server_name: &str) -> &[String] {
|
||||
self.plugin_display_names_by_mcp_server_name
|
||||
.get(server_name)
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or(&[])
|
||||
}
|
||||
|
||||
fn from_capability_summaries(capability_summaries: &[PluginCapabilitySummary]) -> Self {
|
||||
let mut tool_plugin_provenance = Self::default();
|
||||
for plugin in capability_summaries {
|
||||
for connector_id in &plugin.app_connector_ids {
|
||||
tool_plugin_provenance
|
||||
.plugin_display_names_by_connector_id
|
||||
.entry(connector_id.0.clone())
|
||||
.or_default()
|
||||
.push(plugin.display_name.clone());
|
||||
}
|
||||
|
||||
for server_name in &plugin.mcp_server_names {
|
||||
tool_plugin_provenance
|
||||
.plugin_display_names_by_mcp_server_name
|
||||
.entry(server_name.clone())
|
||||
.or_default()
|
||||
.push(plugin.display_name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
for plugin_names in tool_plugin_provenance
|
||||
.plugin_display_names_by_connector_id
|
||||
.values_mut()
|
||||
.chain(
|
||||
tool_plugin_provenance
|
||||
.plugin_display_names_by_mcp_server_name
|
||||
.values_mut(),
|
||||
)
|
||||
{
|
||||
plugin_names.sort_unstable();
|
||||
plugin_names.dedup();
|
||||
}
|
||||
|
||||
tool_plugin_provenance
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy vs new MCP gateway
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum CodexAppsMcpGateway {
|
||||
@@ -182,6 +241,11 @@ impl McpManager {
|
||||
) -> HashMap<String, McpServerConfig> {
|
||||
effective_mcp_servers(config, auth, self.plugins_manager.as_ref())
|
||||
}
|
||||
|
||||
pub fn tool_plugin_provenance(&self, config: &Config) -> ToolPluginProvenance {
|
||||
let loaded_plugins = self.plugins_manager.plugins_for_config(config);
|
||||
ToolPluginProvenance::from_capability_summaries(loaded_plugins.capability_summaries())
|
||||
}
|
||||
}
|
||||
|
||||
fn configured_mcp_servers(
|
||||
@@ -219,6 +283,7 @@ pub async fn collect_mcp_snapshot(config: &Config) -> McpListToolsResponseEvent
|
||||
let auth = auth_manager.auth().await;
|
||||
let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(config.codex_home.clone())));
|
||||
let mcp_servers = mcp_manager.effective_servers(config, auth.as_ref());
|
||||
let tool_plugin_provenance = mcp_manager.tool_plugin_provenance(config);
|
||||
if mcp_servers.is_empty() {
|
||||
return McpListToolsResponseEvent {
|
||||
tools: HashMap::new(),
|
||||
@@ -251,6 +316,7 @@ pub async fn collect_mcp_snapshot(config: &Config) -> McpListToolsResponseEvent
|
||||
sandbox_state,
|
||||
config.codex_home.clone(),
|
||||
codex_apps_tools_cache_key(auth.as_ref()),
|
||||
tool_plugin_provenance,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -407,6 +473,8 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::config::CONFIG_TOML_FILE;
|
||||
use crate::config::ConfigBuilder;
|
||||
use crate::plugins::AppConnectorId;
|
||||
use crate::plugins::PluginCapabilitySummary;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
@@ -485,6 +553,47 @@ mod tests {
|
||||
assert_eq!(group_tools_by_server(&tools), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_plugin_provenance_collects_app_and_mcp_sources() {
|
||||
let provenance = ToolPluginProvenance::from_capability_summaries(&[
|
||||
PluginCapabilitySummary {
|
||||
display_name: "alpha-plugin".to_string(),
|
||||
app_connector_ids: vec![AppConnectorId("connector_example".to_string())],
|
||||
mcp_server_names: vec!["alpha".to_string()],
|
||||
..PluginCapabilitySummary::default()
|
||||
},
|
||||
PluginCapabilitySummary {
|
||||
display_name: "beta-plugin".to_string(),
|
||||
app_connector_ids: vec![
|
||||
AppConnectorId("connector_example".to_string()),
|
||||
AppConnectorId("connector_gmail".to_string()),
|
||||
],
|
||||
mcp_server_names: vec!["beta".to_string()],
|
||||
..PluginCapabilitySummary::default()
|
||||
},
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
provenance,
|
||||
ToolPluginProvenance {
|
||||
plugin_display_names_by_connector_id: HashMap::from([
|
||||
(
|
||||
"connector_example".to_string(),
|
||||
vec!["alpha-plugin".to_string(), "beta-plugin".to_string()],
|
||||
),
|
||||
(
|
||||
"connector_gmail".to_string(),
|
||||
vec!["beta-plugin".to_string()],
|
||||
),
|
||||
]),
|
||||
plugin_display_names_by_mcp_server_name: HashMap::from([
|
||||
("alpha".to_string(), vec!["alpha-plugin".to_string()]),
|
||||
("beta".to_string(), vec!["beta-plugin".to_string()]),
|
||||
]),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_apps_mcp_url_for_default_gateway_keeps_existing_paths() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//! in a single aggregated map using the fully-qualified tool name
|
||||
//! `"<server><MCP_TOOL_NAME_DELIMITER><tool>"` as the key.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::env;
|
||||
@@ -19,6 +20,7 @@ use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use crate::mcp::ToolPluginProvenance;
|
||||
use crate::mcp::auth::McpAuthStatusEntry;
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
@@ -80,7 +82,6 @@ use crate::codex::INITIAL_SUBMIT_ID;
|
||||
use crate::config::types::McpServerConfig;
|
||||
use crate::config::types::McpServerTransportConfig;
|
||||
use crate::connectors::is_connector_id_allowed;
|
||||
|
||||
/// Delimiter used to separate the server name from the tool name in a fully
|
||||
/// qualified tool name.
|
||||
///
|
||||
@@ -198,6 +199,8 @@ pub(crate) struct ToolInfo {
|
||||
pub(crate) tool: Tool,
|
||||
pub(crate) connector_id: Option<String>,
|
||||
pub(crate) connector_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) plugin_display_names: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -414,9 +417,13 @@ struct AsyncManagedClient {
|
||||
client: Shared<BoxFuture<'static, Result<ManagedClient, StartupOutcomeError>>>,
|
||||
startup_snapshot: Option<Vec<ToolInfo>>,
|
||||
startup_complete: Arc<AtomicBool>,
|
||||
tool_plugin_provenance: Arc<ToolPluginProvenance>,
|
||||
}
|
||||
|
||||
impl AsyncManagedClient {
|
||||
// Keep this constructor flat so the startup inputs remain readable at the
|
||||
// single call site instead of introducing a one-off params wrapper.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn new(
|
||||
server_name: String,
|
||||
config: McpServerConfig,
|
||||
@@ -425,6 +432,7 @@ impl AsyncManagedClient {
|
||||
tx_event: Sender<Event>,
|
||||
elicitation_requests: ElicitationRequestManager,
|
||||
codex_apps_tools_cache_context: Option<CodexAppsToolsCacheContext>,
|
||||
tool_plugin_provenance: Arc<ToolPluginProvenance>,
|
||||
) -> Self {
|
||||
let tool_filter = ToolFilter::from_config(&config);
|
||||
let startup_snapshot = load_startup_cached_codex_apps_tools_snapshot(
|
||||
@@ -481,6 +489,7 @@ impl AsyncManagedClient {
|
||||
client,
|
||||
startup_snapshot,
|
||||
startup_complete,
|
||||
tool_plugin_provenance,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -496,14 +505,63 @@ impl AsyncManagedClient {
|
||||
}
|
||||
|
||||
async fn listed_tools(&self) -> Option<Vec<ToolInfo>> {
|
||||
if let Some(startup_tools) = self.startup_snapshot_while_initializing() {
|
||||
return Some(startup_tools);
|
||||
}
|
||||
let annotate_tools = |tools: Vec<ToolInfo>| {
|
||||
let mut tools = tools;
|
||||
for tool in &mut tools {
|
||||
let plugin_names = match tool.connector_id.as_deref() {
|
||||
Some(connector_id) => self
|
||||
.tool_plugin_provenance
|
||||
.plugin_display_names_for_connector_id(connector_id),
|
||||
None => self
|
||||
.tool_plugin_provenance
|
||||
.plugin_display_names_for_mcp_server_name(tool.server_name.as_str()),
|
||||
};
|
||||
tool.plugin_display_names = plugin_names.to_vec();
|
||||
|
||||
match self.client().await {
|
||||
Ok(client) => Some(client.listed_tools()),
|
||||
Err(_) => self.startup_snapshot.clone(),
|
||||
}
|
||||
if plugin_names.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let plugin_source_note = if plugin_names.len() == 1 {
|
||||
format!("This tool is part of plugin `{}`.", plugin_names[0])
|
||||
} else {
|
||||
format!(
|
||||
"This tool is part of plugins {}.",
|
||||
plugin_names
|
||||
.iter()
|
||||
.map(|plugin_name| format!("`{plugin_name}`"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)
|
||||
};
|
||||
let description = tool
|
||||
.tool
|
||||
.description
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.unwrap_or("");
|
||||
let annotated_description = if description.is_empty() {
|
||||
plugin_source_note
|
||||
} else if matches!(description.chars().last(), Some('.' | '!' | '?')) {
|
||||
format!("{description} {plugin_source_note}")
|
||||
} else {
|
||||
format!("{description}. {plugin_source_note}")
|
||||
};
|
||||
tool.tool.description = Some(Cow::Owned(annotated_description));
|
||||
}
|
||||
tools
|
||||
};
|
||||
|
||||
// Keep cache payloads raw; plugin provenance is resolved per-session at read time.
|
||||
let tools = if let Some(startup_tools) = self.startup_snapshot_while_initializing() {
|
||||
Some(startup_tools)
|
||||
} else {
|
||||
match self.client().await {
|
||||
Ok(client) => Some(client.listed_tools()),
|
||||
Err(_) => self.startup_snapshot.clone(),
|
||||
}
|
||||
};
|
||||
tools.map(annotate_tools)
|
||||
}
|
||||
|
||||
async fn notify_sandbox_state_change(&self, sandbox_state: &SandboxState) -> Result<()> {
|
||||
@@ -575,12 +633,14 @@ impl McpConnectionManager {
|
||||
initial_sandbox_state: SandboxState,
|
||||
codex_home: PathBuf,
|
||||
codex_apps_tools_cache_key: CodexAppsToolsCacheKey,
|
||||
tool_plugin_provenance: ToolPluginProvenance,
|
||||
) -> (Self, CancellationToken) {
|
||||
let cancel_token = CancellationToken::new();
|
||||
let mut clients = HashMap::new();
|
||||
let mut server_origins = HashMap::new();
|
||||
let mut join_set = JoinSet::new();
|
||||
let elicitation_requests = ElicitationRequestManager::new(approval_policy.value());
|
||||
let tool_plugin_provenance = Arc::new(tool_plugin_provenance);
|
||||
let mcp_servers = mcp_servers.clone();
|
||||
for (server_name, cfg) in mcp_servers.into_iter().filter(|(_, cfg)| cfg.enabled) {
|
||||
if let Some(origin) = transport_origin(&cfg.transport) {
|
||||
@@ -611,6 +671,7 @@ impl McpConnectionManager {
|
||||
tx_event.clone(),
|
||||
elicitation_requests.clone(),
|
||||
codex_apps_tools_cache_context,
|
||||
Arc::clone(&tool_plugin_provenance),
|
||||
);
|
||||
clients.insert(server_name.clone(), async_managed_client.clone());
|
||||
let tx_event = tx_event.clone();
|
||||
@@ -1518,6 +1579,7 @@ async fn list_tools_for_client_uncached(
|
||||
tool: tool_def,
|
||||
connector_id: tool.connector_id,
|
||||
connector_name,
|
||||
plugin_display_names: Vec::new(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -1631,6 +1693,7 @@ mod tests {
|
||||
},
|
||||
connector_id: None,
|
||||
connector_name: None,
|
||||
plugin_display_names: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2014,6 +2077,7 @@ mod tests {
|
||||
client: pending_client,
|
||||
startup_snapshot: Some(startup_tools),
|
||||
startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2039,6 +2103,7 @@ mod tests {
|
||||
client: pending_client,
|
||||
startup_snapshot: None,
|
||||
startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2061,6 +2126,7 @@ mod tests {
|
||||
client: pending_client,
|
||||
startup_snapshot: Some(Vec::new()),
|
||||
startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2092,6 +2158,7 @@ mod tests {
|
||||
client: failed_client,
|
||||
startup_snapshot: Some(startup_tools),
|
||||
startup_complete,
|
||||
tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()),
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::path::PathBuf;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
|
||||
use crate::connectors;
|
||||
use crate::plugins::PluginCapabilitySummary;
|
||||
use crate::skills::SkillMetadata;
|
||||
use crate::skills::injection::ToolMentionKind;
|
||||
use crate::skills::injection::app_id_from_path;
|
||||
@@ -48,6 +49,103 @@ pub(crate) fn collect_explicit_app_ids(input: &[UserInput]) -> HashSet<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Collect explicit plain-text `@plugin` mentions from user text.
|
||||
///
|
||||
/// This is currently the core-side fallback path for plugin mentions. It
|
||||
/// matches unambiguous plugin `display_name`s from the filtered capability
|
||||
/// index, case-insensitively, by scanning for exact `@display name` matches.
|
||||
///
|
||||
/// It is hand-rolled because core only has a `$...` / `[$...](...)` mention
|
||||
/// parser today, and the existing TUI `@...` logic is file-autocomplete, not
|
||||
/// turn-time parsing.
|
||||
///
|
||||
/// Long term, explicit plugin picks should come through structured
|
||||
/// `plugin://...` mentions, likely via `UserInput::Mention`, once clients can list
|
||||
/// plugins and the UI has plugin-mention support (likely a plugins/list app-server
|
||||
/// endpoint). Even then, this may stay as a text fallback, similar to skills/apps.
|
||||
pub(crate) fn collect_explicit_plugin_mentions(
|
||||
input: &[UserInput],
|
||||
plugins: &[PluginCapabilitySummary],
|
||||
) -> Vec<PluginCapabilitySummary> {
|
||||
if plugins.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut display_name_counts = HashMap::new();
|
||||
for plugin in plugins {
|
||||
*display_name_counts
|
||||
.entry(plugin.display_name.to_lowercase())
|
||||
.or_insert(0) += 1;
|
||||
}
|
||||
|
||||
let mut display_names = display_name_counts.keys().cloned().collect::<Vec<_>>();
|
||||
display_names.sort_by_key(|display_name| std::cmp::Reverse(display_name.len()));
|
||||
|
||||
let mut mentioned_display_names = HashSet::new();
|
||||
for text in input.iter().filter_map(|item| match item {
|
||||
UserInput::Text { text, .. } => Some(text.as_str()),
|
||||
_ => None,
|
||||
}) {
|
||||
let text = text.to_lowercase();
|
||||
let mut index = 0;
|
||||
while let Some(relative_at_sign) = text[index..].find('@') {
|
||||
let at_sign = index + relative_at_sign;
|
||||
if text[..at_sign]
|
||||
.chars()
|
||||
.next_back()
|
||||
.is_some_and(is_plugin_mention_body_char)
|
||||
{
|
||||
index = at_sign + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some((matched_display_name, matched_len)) =
|
||||
display_names.iter().find_map(|display_name| {
|
||||
text[at_sign + 1..].starts_with(display_name).then(|| {
|
||||
let end = at_sign + 1 + display_name.len();
|
||||
text[end..]
|
||||
.chars()
|
||||
.next()
|
||||
.is_none_or(|ch| !is_plugin_mention_body_char(ch))
|
||||
.then_some((display_name, display_name.len()))
|
||||
})?
|
||||
})
|
||||
else {
|
||||
index = at_sign + 1;
|
||||
continue;
|
||||
};
|
||||
|
||||
if display_name_counts
|
||||
.get(matched_display_name)
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
== 1
|
||||
{
|
||||
mentioned_display_names.insert(matched_display_name.clone());
|
||||
}
|
||||
index = at_sign + 1 + matched_len;
|
||||
}
|
||||
}
|
||||
|
||||
if mentioned_display_names.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut selected = Vec::new();
|
||||
let mut seen_display_names = HashSet::new();
|
||||
for plugin in plugins {
|
||||
let display_name = plugin.display_name.to_lowercase();
|
||||
if !mentioned_display_names.contains(&display_name) {
|
||||
continue;
|
||||
}
|
||||
if seen_display_names.insert(display_name) {
|
||||
selected.push(plugin.clone());
|
||||
}
|
||||
}
|
||||
|
||||
selected
|
||||
}
|
||||
|
||||
pub(crate) fn build_skill_name_counts(
|
||||
skills: &[SkillMetadata],
|
||||
disabled_paths: &HashSet<PathBuf>,
|
||||
@@ -77,6 +175,10 @@ pub(crate) fn build_connector_slug_counts(
|
||||
counts
|
||||
}
|
||||
|
||||
fn is_plugin_mention_body_char(ch: char) -> bool {
|
||||
ch.is_alphanumeric() || matches!(ch, '_' | '-' | ':')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashSet;
|
||||
@@ -85,6 +187,8 @@ mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::collect_explicit_app_ids;
|
||||
use super::collect_explicit_plugin_mentions;
|
||||
use crate::plugins::PluginCapabilitySummary;
|
||||
|
||||
fn text_input(text: &str) -> UserInput {
|
||||
UserInput::Text {
|
||||
@@ -93,6 +197,16 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn plugin(display_name: &str) -> PluginCapabilitySummary {
|
||||
PluginCapabilitySummary {
|
||||
config_name: format!("{display_name}@test"),
|
||||
display_name: display_name.to_string(),
|
||||
has_skills: true,
|
||||
mcp_server_names: Vec::new(),
|
||||
app_connector_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_explicit_app_ids_from_linked_text_mentions() {
|
||||
let input = vec")];
|
||||
@@ -141,4 +255,70 @@ mod tests {
|
||||
|
||||
assert_eq!(app_ids, HashSet::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_explicit_plugin_mentions_resolves_unique_display_names() {
|
||||
let plugins = vec![plugin("sample"), plugin("other")];
|
||||
|
||||
let mentioned = collect_explicit_plugin_mentions(&[text_input("use @sample")], &plugins);
|
||||
|
||||
assert_eq!(mentioned, vec![plugin("sample")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_explicit_plugin_mentions_resolves_non_slug_display_names() {
|
||||
let spaced_plugins = vec![plugin("Google Calendar")];
|
||||
let spaced_mentioned = collect_explicit_plugin_mentions(
|
||||
&[text_input("use @Google Calendar")],
|
||||
&spaced_plugins,
|
||||
);
|
||||
assert_eq!(spaced_mentioned, vec![plugin("Google Calendar")]);
|
||||
|
||||
let unicode_plugins = vec![plugin("Café")];
|
||||
let unicode_mentioned =
|
||||
collect_explicit_plugin_mentions(&[text_input("use @Café")], &unicode_plugins);
|
||||
assert_eq!(unicode_mentioned, vec![plugin("Café")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_explicit_plugin_mentions_prefers_longer_display_names() {
|
||||
let plugins = vec![plugin("Google"), plugin("Google Calendar")];
|
||||
|
||||
let mentioned =
|
||||
collect_explicit_plugin_mentions(&[text_input("use @Google Calendar")], &plugins);
|
||||
|
||||
assert_eq!(mentioned, vec![plugin("Google Calendar")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_explicit_plugin_mentions_does_not_fall_back_from_ambiguous_longer_name() {
|
||||
let plugins = vec![
|
||||
plugin("Google"),
|
||||
PluginCapabilitySummary {
|
||||
config_name: "calendar-1@test".to_string(),
|
||||
..plugin("Google Calendar")
|
||||
},
|
||||
PluginCapabilitySummary {
|
||||
config_name: "calendar-2@test".to_string(),
|
||||
..plugin("Google Calendar")
|
||||
},
|
||||
];
|
||||
|
||||
let mentioned =
|
||||
collect_explicit_plugin_mentions(&[text_input("use @Google Calendar")], &plugins);
|
||||
|
||||
assert_eq!(mentioned, Vec::<PluginCapabilitySummary>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_explicit_plugin_mentions_ignores_embedded_at_signs() {
|
||||
let plugins = vec![plugin("sample")];
|
||||
|
||||
let mentioned = collect_explicit_plugin_mentions(
|
||||
&[text_input("contact sample@openai.com, do not use plugins")],
|
||||
&plugins,
|
||||
);
|
||||
|
||||
assert_eq!(mentioned, Vec::<PluginCapabilitySummary>::new());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,11 +146,6 @@ fn apply_network_constraints(network: NetworkToml, constraints: &mut NetworkProx
|
||||
constraints.dangerously_allow_non_loopback_proxy =
|
||||
Some(dangerously_allow_non_loopback_proxy);
|
||||
}
|
||||
if let Some(dangerously_allow_non_loopback_admin) = network.dangerously_allow_non_loopback_admin
|
||||
{
|
||||
constraints.dangerously_allow_non_loopback_admin =
|
||||
Some(dangerously_allow_non_loopback_admin);
|
||||
}
|
||||
if let Some(dangerously_allow_all_unix_sockets) = network.dangerously_allow_all_unix_sockets {
|
||||
constraints.dangerously_allow_all_unix_sockets = Some(dangerously_allow_all_unix_sockets);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use codex_protocol::models::DeveloperInstructions;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
|
||||
use crate::connectors;
|
||||
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use crate::mcp_connection_manager::ToolInfo;
|
||||
use crate::plugins::PluginCapabilitySummary;
|
||||
use crate::plugins::render_explicit_plugin_instructions;
|
||||
|
||||
pub(crate) fn build_plugin_injections(
|
||||
mentioned_plugins: &[PluginCapabilitySummary],
|
||||
mcp_tools: &HashMap<String, ToolInfo>,
|
||||
available_connectors: &[connectors::AppInfo],
|
||||
) -> Vec<ResponseItem> {
|
||||
if mentioned_plugins.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// Turn each explicit @plugin mention into a developer hint that points the
|
||||
// model at the plugin's visible MCP servers, enabled apps, and skill prefix.
|
||||
mentioned_plugins
|
||||
.iter()
|
||||
.filter_map(|plugin| {
|
||||
let available_mcp_servers = mcp_tools
|
||||
.values()
|
||||
.filter(|tool| {
|
||||
tool.server_name != CODEX_APPS_MCP_SERVER_NAME
|
||||
&& tool
|
||||
.plugin_display_names
|
||||
.iter()
|
||||
.any(|plugin_name| plugin_name == &plugin.display_name)
|
||||
})
|
||||
.map(|tool| tool.server_name.clone())
|
||||
.collect::<BTreeSet<String>>()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>();
|
||||
let available_apps = available_connectors
|
||||
.iter()
|
||||
.filter(|connector| {
|
||||
connector.is_enabled
|
||||
&& connector
|
||||
.plugin_display_names
|
||||
.iter()
|
||||
.any(|plugin_name| plugin_name == &plugin.display_name)
|
||||
})
|
||||
.map(connectors::connector_display_label)
|
||||
.collect::<BTreeSet<String>>()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>();
|
||||
render_explicit_plugin_instructions(plugin, &available_mcp_servers, &available_apps)
|
||||
.map(DeveloperInstructions::new)
|
||||
.map(ResponseItem::from)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
use super::load_plugin_manifest;
|
||||
use super::marketplace::MarketplaceError;
|
||||
use super::marketplace::MarketplacePluginSourceSummary;
|
||||
use super::marketplace::list_marketplaces;
|
||||
use super::marketplace::resolve_marketplace_plugin;
|
||||
use super::plugin_manifest_name;
|
||||
use super::store::DEFAULT_PLUGIN_VERSION;
|
||||
@@ -26,6 +28,7 @@ use serde_json::Map as JsonMap;
|
||||
use serde_json::Value as JsonValue;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
@@ -42,8 +45,21 @@ pub struct AppConnectorId(pub String);
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginInstallRequest {
|
||||
pub plugin_name: String,
|
||||
pub marketplace_name: String,
|
||||
pub cwd: PathBuf,
|
||||
pub marketplace_path: AbsolutePathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ConfiguredMarketplaceSummary {
|
||||
pub name: String,
|
||||
pub path: PathBuf,
|
||||
pub plugins: Vec<ConfiguredMarketplacePluginSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ConfiguredMarketplacePluginSummary {
|
||||
pub name: String,
|
||||
pub source: MarketplacePluginSourceSummary,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -198,11 +214,6 @@ impl PluginsManager {
|
||||
force_reload: bool,
|
||||
) -> PluginLoadOutcome {
|
||||
if !plugins_feature_enabled_from_stack(config_layer_stack) {
|
||||
let mut cache = match self.cache_by_cwd.write() {
|
||||
Ok(cache) => cache,
|
||||
Err(err) => err.into_inner(),
|
||||
};
|
||||
cache.insert(cwd.to_path_buf(), PluginLoadOutcome::default());
|
||||
return PluginLoadOutcome::default();
|
||||
}
|
||||
|
||||
@@ -239,11 +250,7 @@ impl PluginsManager {
|
||||
&self,
|
||||
request: PluginInstallRequest,
|
||||
) -> Result<PluginInstallResult, PluginInstallError> {
|
||||
let resolved = resolve_marketplace_plugin(
|
||||
&request.cwd,
|
||||
&request.plugin_name,
|
||||
&request.marketplace_name,
|
||||
)?;
|
||||
let resolved = resolve_marketplace_plugin(&request.marketplace_path, &request.plugin_name)?;
|
||||
let store = self.store.clone();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
store.install(resolved.source_path.into_path_buf(), resolved.plugin_id)
|
||||
@@ -267,6 +274,56 @@ impl PluginsManager {
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn list_marketplaces_for_config(
|
||||
&self,
|
||||
config: &Config,
|
||||
additional_roots: &[AbsolutePathBuf],
|
||||
) -> Result<Vec<ConfiguredMarketplaceSummary>, MarketplaceError> {
|
||||
let configured_plugins = self
|
||||
.plugins_for_config(config)
|
||||
.plugins()
|
||||
.iter()
|
||||
.map(|plugin| (plugin.config_name.clone(), plugin.enabled))
|
||||
.collect::<HashMap<String, bool>>();
|
||||
let marketplaces = list_marketplaces(additional_roots)?;
|
||||
let mut seen_plugin_keys = HashSet::new();
|
||||
|
||||
Ok(marketplaces
|
||||
.into_iter()
|
||||
.filter_map(|marketplace| {
|
||||
let marketplace_name = marketplace.name.clone();
|
||||
let plugins = marketplace
|
||||
.plugins
|
||||
.into_iter()
|
||||
.filter_map(|plugin| {
|
||||
let plugin_key = format!("{}@{marketplace_name}", plugin.name);
|
||||
if !seen_plugin_keys.insert(plugin_key.clone()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(ConfiguredMarketplacePluginSummary {
|
||||
// Enabled state is keyed by `<plugin>@<marketplace>`, so duplicate
|
||||
// plugin entries from duplicate marketplace files intentionally
|
||||
// resolve to the first discovered source.
|
||||
enabled: configured_plugins
|
||||
.get(&plugin_key)
|
||||
.copied()
|
||||
.unwrap_or(false),
|
||||
name: plugin.name,
|
||||
source: plugin.source,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
(!plugins.is_empty()).then_some(ConfiguredMarketplaceSummary {
|
||||
name: marketplace.name,
|
||||
path: marketplace.path,
|
||||
plugins,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -293,9 +350,9 @@ impl PluginInstallError {
|
||||
matches!(
|
||||
self,
|
||||
Self::Marketplace(
|
||||
MarketplaceError::InvalidMarketplaceFile { .. }
|
||||
MarketplaceError::MarketplaceNotFound { .. }
|
||||
| MarketplaceError::InvalidMarketplaceFile { .. }
|
||||
| MarketplaceError::PluginNotFound { .. }
|
||||
| MarketplaceError::DuplicatePlugin { .. }
|
||||
| MarketplaceError::InvalidPlugin(_)
|
||||
) | Self::Store(PluginStoreError::Invalid(_))
|
||||
)
|
||||
@@ -466,10 +523,7 @@ fn load_plugin(config_name: String, plugin: &PluginConfig, store: &PluginStore)
|
||||
}
|
||||
}
|
||||
loaded_plugin.mcp_servers = mcp_servers;
|
||||
loaded_plugin.apps = load_apps_from_file(
|
||||
plugin_root.as_path(),
|
||||
&plugin_root.as_path().join(DEFAULT_APP_CONFIG_FILE),
|
||||
);
|
||||
loaded_plugin.apps = load_plugin_apps(plugin_root.as_path());
|
||||
loaded_plugin
|
||||
}
|
||||
|
||||
@@ -493,6 +547,10 @@ fn default_mcp_config_paths(plugin_root: &Path) -> Vec<PathBuf> {
|
||||
paths
|
||||
}
|
||||
|
||||
pub fn load_plugin_apps(plugin_root: &Path) -> Vec<AppConnectorId> {
|
||||
load_apps_from_file(plugin_root, &plugin_root.join(DEFAULT_APP_CONFIG_FILE))
|
||||
}
|
||||
|
||||
fn load_apps_from_file(plugin_root: &Path, app_config_path: &Path) -> Vec<AppConnectorId> {
|
||||
let Ok(contents) = fs::read_to_string(app_config_path) else {
|
||||
return Vec::new();
|
||||
@@ -1091,8 +1149,10 @@ mod tests {
|
||||
let result = PluginsManager::new(tmp.path().to_path_buf())
|
||||
.install_plugin(PluginInstallRequest {
|
||||
plugin_name: "sample-plugin".to_string(),
|
||||
marketplace_name: "debug".to_string(),
|
||||
cwd: repo_root.clone(),
|
||||
marketplace_path: AbsolutePathBuf::try_from(
|
||||
repo_root.join(".agents/plugins/marketplace.json"),
|
||||
)
|
||||
.unwrap(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1111,4 +1171,207 @@ mod tests {
|
||||
assert!(config.contains(r#"[plugins."sample-plugin@debug"]"#));
|
||||
assert!(config.contains("enabled = true"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_marketplaces_for_config_includes_enabled_state() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
||||
fs::write(
|
||||
repo_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "debug",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "enabled-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./enabled-plugin"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "disabled-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./disabled-plugin"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
write_file(
|
||||
&tmp.path().join(CONFIG_TOML_FILE),
|
||||
r#"[features]
|
||||
plugins = true
|
||||
|
||||
[plugins."enabled-plugin@debug"]
|
||||
enabled = true
|
||||
|
||||
[plugins."disabled-plugin@debug"]
|
||||
enabled = false
|
||||
"#,
|
||||
);
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(tmp.path().to_path_buf())
|
||||
.build()
|
||||
.await
|
||||
.expect("config should load");
|
||||
|
||||
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
|
||||
.list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()])
|
||||
.unwrap();
|
||||
|
||||
let marketplace = marketplaces
|
||||
.into_iter()
|
||||
.find(|marketplace| {
|
||||
marketplace.path == tmp.path().join("repo/.agents/plugins/marketplace.json")
|
||||
})
|
||||
.expect("expected repo marketplace entry");
|
||||
|
||||
assert_eq!(
|
||||
marketplace,
|
||||
ConfiguredMarketplaceSummary {
|
||||
name: "debug".to_string(),
|
||||
path: tmp.path().join("repo/.agents/plugins/marketplace.json"),
|
||||
plugins: vec![
|
||||
ConfiguredMarketplacePluginSummary {
|
||||
name: "enabled-plugin".to_string(),
|
||||
source: MarketplacePluginSourceSummary::Local {
|
||||
path: tmp.path().join("repo/.agents/plugins/enabled-plugin"),
|
||||
},
|
||||
enabled: true,
|
||||
},
|
||||
ConfiguredMarketplacePluginSummary {
|
||||
name: "disabled-plugin".to_string(),
|
||||
source: MarketplacePluginSourceSummary::Local {
|
||||
path: tmp.path().join("repo/.agents/plugins/disabled-plugin"),
|
||||
},
|
||||
enabled: false,
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_marketplaces_for_config_uses_first_duplicate_plugin_entry() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo_a_root = tmp.path().join("repo-a");
|
||||
let repo_b_root = tmp.path().join("repo-b");
|
||||
fs::create_dir_all(repo_a_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(repo_b_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(repo_a_root.join(".agents/plugins")).unwrap();
|
||||
fs::create_dir_all(repo_b_root.join(".agents/plugins")).unwrap();
|
||||
fs::write(
|
||||
repo_a_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "debug",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "dup-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./from-a"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
repo_b_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "debug",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "dup-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./from-b"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "b-only-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./from-b-only"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
write_file(
|
||||
&tmp.path().join(CONFIG_TOML_FILE),
|
||||
r#"[features]
|
||||
plugins = true
|
||||
|
||||
[plugins."dup-plugin@debug"]
|
||||
enabled = true
|
||||
|
||||
[plugins."b-only-plugin@debug"]
|
||||
enabled = false
|
||||
"#,
|
||||
);
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(tmp.path().to_path_buf())
|
||||
.build()
|
||||
.await
|
||||
.expect("config should load");
|
||||
|
||||
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
|
||||
.list_marketplaces_for_config(
|
||||
&config,
|
||||
&[
|
||||
AbsolutePathBuf::try_from(repo_a_root).unwrap(),
|
||||
AbsolutePathBuf::try_from(repo_b_root).unwrap(),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let repo_a_marketplace = marketplaces
|
||||
.iter()
|
||||
.find(|marketplace| {
|
||||
marketplace.path == tmp.path().join("repo-a/.agents/plugins/marketplace.json")
|
||||
})
|
||||
.expect("repo-a marketplace should be listed");
|
||||
assert_eq!(
|
||||
repo_a_marketplace.plugins,
|
||||
vec![ConfiguredMarketplacePluginSummary {
|
||||
name: "dup-plugin".to_string(),
|
||||
source: MarketplacePluginSourceSummary::Local {
|
||||
path: tmp.path().join("repo-a/.agents/plugins/from-a"),
|
||||
},
|
||||
enabled: true,
|
||||
}]
|
||||
);
|
||||
|
||||
let repo_b_marketplace = marketplaces
|
||||
.iter()
|
||||
.find(|marketplace| {
|
||||
marketplace.path == tmp.path().join("repo-b/.agents/plugins/marketplace.json")
|
||||
})
|
||||
.expect("repo-b marketplace should be listed");
|
||||
assert_eq!(
|
||||
repo_b_marketplace.plugins,
|
||||
vec![ConfiguredMarketplacePluginSummary {
|
||||
name: "b-only-plugin".to_string(),
|
||||
source: MarketplacePluginSourceSummary::Local {
|
||||
path: tmp.path().join("repo-b/.agents/plugins/from-b-only"),
|
||||
},
|
||||
enabled: false,
|
||||
}]
|
||||
);
|
||||
|
||||
let duplicate_plugin_count = marketplaces
|
||||
.iter()
|
||||
.flat_map(|marketplace| marketplace.plugins.iter())
|
||||
.filter(|plugin| plugin.name == "dup-plugin")
|
||||
.count();
|
||||
assert_eq!(duplicate_plugin_count, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,24 @@ pub struct ResolvedMarketplacePlugin {
|
||||
pub source_path: AbsolutePathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MarketplaceSummary {
|
||||
pub name: String,
|
||||
pub path: PathBuf,
|
||||
pub plugins: Vec<MarketplacePluginSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MarketplacePluginSummary {
|
||||
pub name: String,
|
||||
pub source: MarketplacePluginSourceSummary,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum MarketplacePluginSourceSummary {
|
||||
Local { path: PathBuf },
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum MarketplaceError {
|
||||
#[error("{context}: {source}")]
|
||||
@@ -27,6 +45,9 @@ pub enum MarketplaceError {
|
||||
source: io::Error,
|
||||
},
|
||||
|
||||
#[error("marketplace file `{path}` does not exist")]
|
||||
MarketplaceNotFound { path: PathBuf },
|
||||
|
||||
#[error("invalid marketplace file `{path}`: {message}")]
|
||||
InvalidMarketplaceFile { path: PathBuf, message: String },
|
||||
|
||||
@@ -36,14 +57,6 @@ pub enum MarketplaceError {
|
||||
marketplace_name: String,
|
||||
},
|
||||
|
||||
#[error(
|
||||
"multiple marketplace plugin entries matched `{plugin_name}` in marketplace `{marketplace_name}`"
|
||||
)]
|
||||
DuplicatePlugin {
|
||||
plugin_name: String,
|
||||
marketplace_name: String,
|
||||
},
|
||||
|
||||
#[error("{0}")]
|
||||
InvalidPlugin(String),
|
||||
}
|
||||
@@ -54,77 +67,97 @@ impl MarketplaceError {
|
||||
}
|
||||
}
|
||||
|
||||
// For now, marketplace discovery always reads from disk so installs see the latest
|
||||
// marketplace.json contents without any in-memory cache invalidation.
|
||||
// Always read the specified marketplace file from disk so installs see the
|
||||
// latest marketplace.json contents without any in-memory cache invalidation.
|
||||
pub fn resolve_marketplace_plugin(
|
||||
cwd: &Path,
|
||||
marketplace_path: &AbsolutePathBuf,
|
||||
plugin_name: &str,
|
||||
marketplace_name: &str,
|
||||
) -> Result<ResolvedMarketplacePlugin, MarketplaceError> {
|
||||
resolve_marketplace_plugin_from_paths(
|
||||
&discover_marketplace_paths(cwd),
|
||||
plugin_name,
|
||||
marketplace_name,
|
||||
)
|
||||
}
|
||||
let marketplace = load_marketplace(marketplace_path.as_path())?;
|
||||
let marketplace_name = marketplace.name;
|
||||
let plugin = marketplace
|
||||
.plugins
|
||||
.into_iter()
|
||||
.find(|plugin| plugin.name == plugin_name);
|
||||
|
||||
fn resolve_marketplace_plugin_from_paths(
|
||||
marketplace_paths: &[PathBuf],
|
||||
plugin_name: &str,
|
||||
marketplace_name: &str,
|
||||
) -> Result<ResolvedMarketplacePlugin, MarketplaceError> {
|
||||
for marketplace_path in marketplace_paths {
|
||||
let marketplace = load_marketplace(marketplace_path)?;
|
||||
let discovered_marketplace_name = marketplace.name;
|
||||
let mut matches = marketplace
|
||||
.plugins
|
||||
.into_iter()
|
||||
.filter(|plugin| plugin.name == plugin_name)
|
||||
.collect::<Vec<_>>();
|
||||
let Some(plugin) = plugin else {
|
||||
return Err(MarketplaceError::PluginNotFound {
|
||||
plugin_name: plugin_name.to_string(),
|
||||
marketplace_name,
|
||||
});
|
||||
};
|
||||
|
||||
if discovered_marketplace_name != marketplace_name || matches.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if matches.len() > 1 {
|
||||
return Err(MarketplaceError::DuplicatePlugin {
|
||||
plugin_name: plugin_name.to_string(),
|
||||
marketplace_name: marketplace_name.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(plugin) = matches.pop() {
|
||||
let plugin_id = PluginId::new(plugin.name, marketplace_name.to_string()).map_err(
|
||||
|err| match err {
|
||||
PluginIdError::Invalid(message) => MarketplaceError::InvalidPlugin(message),
|
||||
},
|
||||
)?;
|
||||
return Ok(ResolvedMarketplacePlugin {
|
||||
plugin_id,
|
||||
source_path: resolve_plugin_source_path(marketplace_path, plugin.source)?,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Err(MarketplaceError::PluginNotFound {
|
||||
plugin_name: plugin_name.to_string(),
|
||||
marketplace_name: marketplace_name.to_string(),
|
||||
let plugin_id = PluginId::new(plugin.name, marketplace_name).map_err(|err| match err {
|
||||
PluginIdError::Invalid(message) => MarketplaceError::InvalidPlugin(message),
|
||||
})?;
|
||||
Ok(ResolvedMarketplacePlugin {
|
||||
plugin_id,
|
||||
source_path: resolve_plugin_source_path(marketplace_path.as_path(), plugin.source)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn discover_marketplace_paths(cwd: &Path) -> Vec<PathBuf> {
|
||||
pub fn list_marketplaces(
|
||||
additional_roots: &[AbsolutePathBuf],
|
||||
) -> Result<Vec<MarketplaceSummary>, MarketplaceError> {
|
||||
list_marketplaces_with_home(additional_roots, home_dir().as_deref())
|
||||
}
|
||||
|
||||
fn list_marketplaces_with_home(
|
||||
additional_roots: &[AbsolutePathBuf],
|
||||
home_dir: Option<&Path>,
|
||||
) -> Result<Vec<MarketplaceSummary>, MarketplaceError> {
|
||||
let mut marketplaces = Vec::new();
|
||||
|
||||
for marketplace_path in discover_marketplace_paths_from_roots(additional_roots, home_dir) {
|
||||
let marketplace = load_marketplace(marketplace_path.as_path())?;
|
||||
let mut plugins = Vec::new();
|
||||
|
||||
for plugin in marketplace.plugins {
|
||||
let source = match plugin.source {
|
||||
MarketplacePluginSource::Local { path } => MarketplacePluginSourceSummary::Local {
|
||||
path: resolve_plugin_source_path(
|
||||
marketplace_path.as_path(),
|
||||
MarketplacePluginSource::Local { path },
|
||||
)?
|
||||
.into_path_buf(),
|
||||
},
|
||||
};
|
||||
|
||||
plugins.push(MarketplacePluginSummary {
|
||||
name: plugin.name,
|
||||
source,
|
||||
});
|
||||
}
|
||||
|
||||
marketplaces.push(MarketplaceSummary {
|
||||
name: marketplace.name,
|
||||
path: marketplace_path,
|
||||
plugins,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(marketplaces)
|
||||
}
|
||||
|
||||
fn discover_marketplace_paths_from_roots(
|
||||
additional_roots: &[AbsolutePathBuf],
|
||||
home_dir: Option<&Path>,
|
||||
) -> Vec<PathBuf> {
|
||||
let mut paths = Vec::new();
|
||||
if let Some(repo_root) = get_git_repo_root(cwd) {
|
||||
let path = repo_root.join(MARKETPLACE_RELATIVE_PATH);
|
||||
|
||||
if let Some(home) = home_dir {
|
||||
let path = home.join(MARKETPLACE_RELATIVE_PATH);
|
||||
if path.is_file() {
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(home) = home_dir() {
|
||||
let path = home.join(MARKETPLACE_RELATIVE_PATH);
|
||||
if path.is_file() {
|
||||
paths.push(path);
|
||||
for root in additional_roots {
|
||||
if let Some(repo_root) = get_git_repo_root(root.as_path()) {
|
||||
let path = repo_root.join(MARKETPLACE_RELATIVE_PATH);
|
||||
if path.is_file() && !paths.contains(&path) {
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,8 +165,15 @@ fn discover_marketplace_paths(cwd: &Path) -> Vec<PathBuf> {
|
||||
}
|
||||
|
||||
fn load_marketplace(path: &Path) -> Result<MarketplaceFile, MarketplaceError> {
|
||||
let contents = fs::read_to_string(path)
|
||||
.map_err(|err| MarketplaceError::io("failed to read marketplace file", err))?;
|
||||
let contents = fs::read_to_string(path).map_err(|err| {
|
||||
if err.kind() == io::ErrorKind::NotFound {
|
||||
MarketplaceError::MarketplaceNotFound {
|
||||
path: path.to_path_buf(),
|
||||
}
|
||||
} else {
|
||||
MarketplaceError::io("failed to read marketplace file", err)
|
||||
}
|
||||
})?;
|
||||
serde_json::from_str(&contents).map_err(|err| MarketplaceError::InvalidMarketplaceFile {
|
||||
path: path.to_path_buf(),
|
||||
message: err.to_string(),
|
||||
@@ -233,9 +273,11 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let resolved =
|
||||
resolve_marketplace_plugin(&repo_root.join("nested"), "local-plugin", "codex-curated")
|
||||
.unwrap();
|
||||
let resolved = resolve_marketplace_plugin(
|
||||
&AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(),
|
||||
"local-plugin",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resolved,
|
||||
@@ -260,7 +302,11 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err = resolve_marketplace_plugin(&repo_root, "missing", "codex-curated").unwrap_err();
|
||||
let err = resolve_marketplace_plugin(
|
||||
&AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(),
|
||||
"missing",
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
@@ -269,7 +315,112 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_marketplace_plugin_prefers_repo_over_home_for_same_plugin() {
|
||||
fn list_marketplaces_returns_home_and_repo_marketplaces() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let home_root = tmp.path().join("home");
|
||||
let repo_root = tmp.path().join("repo");
|
||||
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(home_root.join(".agents/plugins")).unwrap();
|
||||
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
||||
fs::write(
|
||||
home_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "codex-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "shared-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./home-shared"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "home-only",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./home-only"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
repo_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "codex-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "shared-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./repo-shared"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "repo-only",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./repo-only"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let marketplaces = list_marketplaces_with_home(
|
||||
&[AbsolutePathBuf::try_from(repo_root.clone()).unwrap()],
|
||||
Some(&home_root),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
marketplaces,
|
||||
vec![
|
||||
MarketplaceSummary {
|
||||
name: "codex-curated".to_string(),
|
||||
path: home_root.join(".agents/plugins/marketplace.json"),
|
||||
plugins: vec![
|
||||
MarketplacePluginSummary {
|
||||
name: "shared-plugin".to_string(),
|
||||
source: MarketplacePluginSourceSummary::Local {
|
||||
path: home_root.join(".agents/plugins/home-shared"),
|
||||
},
|
||||
},
|
||||
MarketplacePluginSummary {
|
||||
name: "home-only".to_string(),
|
||||
source: MarketplacePluginSourceSummary::Local {
|
||||
path: home_root.join(".agents/plugins/home-only"),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
MarketplaceSummary {
|
||||
name: "codex-curated".to_string(),
|
||||
path: repo_root.join(".agents/plugins/marketplace.json"),
|
||||
plugins: vec![
|
||||
MarketplacePluginSummary {
|
||||
name: "shared-plugin".to_string(),
|
||||
source: MarketplacePluginSourceSummary::Local {
|
||||
path: repo_root.join(".agents/plugins/repo-shared"),
|
||||
},
|
||||
},
|
||||
MarketplacePluginSummary {
|
||||
name: "repo-only".to_string(),
|
||||
source: MarketplacePluginSourceSummary::Local {
|
||||
path: repo_root.join(".agents/plugins/repo-only"),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_marketplaces_keeps_distinct_entries_for_same_name() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let home_root = tmp.path().join("home");
|
||||
let repo_root = tmp.path().join("repo");
|
||||
@@ -313,23 +464,97 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let resolved = resolve_marketplace_plugin_from_paths(
|
||||
&[repo_marketplace, home_marketplace],
|
||||
"local-plugin",
|
||||
"codex-curated",
|
||||
let marketplaces = list_marketplaces_with_home(
|
||||
&[AbsolutePathBuf::try_from(repo_root.clone()).unwrap()],
|
||||
Some(&home_root),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resolved,
|
||||
ResolvedMarketplacePlugin {
|
||||
plugin_id: PluginId::new("local-plugin".to_string(), "codex-curated".to_string())
|
||||
.unwrap(),
|
||||
source_path: AbsolutePathBuf::try_from(
|
||||
repo_root.join(".agents/plugins/repo-plugin"),
|
||||
)
|
||||
.unwrap(),
|
||||
}
|
||||
marketplaces,
|
||||
vec![
|
||||
MarketplaceSummary {
|
||||
name: "codex-curated".to_string(),
|
||||
path: home_marketplace,
|
||||
plugins: vec![MarketplacePluginSummary {
|
||||
name: "local-plugin".to_string(),
|
||||
source: MarketplacePluginSourceSummary::Local {
|
||||
path: home_root.join(".agents/plugins/home-plugin"),
|
||||
},
|
||||
}],
|
||||
},
|
||||
MarketplaceSummary {
|
||||
name: "codex-curated".to_string(),
|
||||
path: repo_marketplace.clone(),
|
||||
plugins: vec![MarketplacePluginSummary {
|
||||
name: "local-plugin".to_string(),
|
||||
source: MarketplacePluginSourceSummary::Local {
|
||||
path: repo_root.join(".agents/plugins/repo-plugin"),
|
||||
},
|
||||
}],
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
let resolved = resolve_marketplace_plugin(
|
||||
&AbsolutePathBuf::try_from(repo_marketplace).unwrap(),
|
||||
"local-plugin",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resolved.source_path,
|
||||
AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/repo-plugin")).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_marketplaces_dedupes_multiple_roots_in_same_repo() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
let nested_root = repo_root.join("nested/project");
|
||||
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
||||
fs::create_dir_all(&nested_root).unwrap();
|
||||
fs::write(
|
||||
repo_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "codex-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "local-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugin"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let marketplaces = list_marketplaces_with_home(
|
||||
&[
|
||||
AbsolutePathBuf::try_from(repo_root.clone()).unwrap(),
|
||||
AbsolutePathBuf::try_from(nested_root).unwrap(),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
marketplaces,
|
||||
vec![MarketplaceSummary {
|
||||
name: "codex-curated".to_string(),
|
||||
path: repo_root.join(".agents/plugins/marketplace.json"),
|
||||
plugins: vec![MarketplacePluginSummary {
|
||||
name: "local-plugin".to_string(),
|
||||
source: MarketplacePluginSourceSummary::Local {
|
||||
path: repo_root.join(".agents/plugins/plugin"),
|
||||
},
|
||||
}],
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -356,8 +581,11 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err =
|
||||
resolve_marketplace_plugin(&repo_root, "local-plugin", "codex-curated").unwrap_err();
|
||||
let err = resolve_marketplace_plugin(
|
||||
&AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(),
|
||||
"local-plugin",
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
@@ -367,4 +595,46 @@ mod tests {
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_marketplace_plugin_uses_first_duplicate_entry() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
||||
fs::write(
|
||||
repo_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "codex-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "local-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./first"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "local-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./second"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let resolved = resolve_marketplace_plugin(
|
||||
&AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(),
|
||||
"local-plugin",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resolved.source_path,
|
||||
AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/first")).unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
mod injection;
|
||||
mod manager;
|
||||
mod manifest;
|
||||
mod marketplace;
|
||||
mod render;
|
||||
mod store;
|
||||
|
||||
pub(crate) use injection::build_plugin_injections;
|
||||
pub use manager::AppConnectorId;
|
||||
pub use manager::ConfiguredMarketplacePluginSummary;
|
||||
pub use manager::ConfiguredMarketplaceSummary;
|
||||
pub use manager::LoadedPlugin;
|
||||
pub use manager::PluginCapabilitySummary;
|
||||
pub use manager::PluginInstallError;
|
||||
pub use manager::PluginInstallRequest;
|
||||
pub use manager::PluginLoadOutcome;
|
||||
pub use manager::PluginsManager;
|
||||
pub use manager::load_plugin_apps;
|
||||
pub(crate) use manager::plugin_namespace_for_skill_path;
|
||||
pub(crate) use manifest::load_plugin_manifest;
|
||||
pub(crate) use manifest::plugin_manifest_name;
|
||||
pub use marketplace::MarketplaceError;
|
||||
pub use marketplace::MarketplacePluginSourceSummary;
|
||||
pub(crate) use render::render_explicit_plugin_instructions;
|
||||
pub(crate) use render::render_plugins_section;
|
||||
pub use store::PluginId;
|
||||
pub use store::PluginInstallResult;
|
||||
|
||||
@@ -30,6 +30,54 @@ pub(crate) fn render_plugins_section(plugins: &[PluginCapabilitySummary]) -> Opt
|
||||
Some(lines.join("\n"))
|
||||
}
|
||||
|
||||
pub(crate) fn render_explicit_plugin_instructions(
|
||||
plugin: &PluginCapabilitySummary,
|
||||
available_mcp_servers: &[String],
|
||||
available_apps: &[String],
|
||||
) -> Option<String> {
|
||||
let mut lines = vec![format!(
|
||||
"Capabilities from the `{}` plugin:",
|
||||
plugin.display_name
|
||||
)];
|
||||
|
||||
if plugin.has_skills {
|
||||
lines.push(format!(
|
||||
"- Skills from this plugin are prefixed with `{}:`.",
|
||||
plugin.display_name
|
||||
));
|
||||
}
|
||||
|
||||
if !available_mcp_servers.is_empty() {
|
||||
lines.push(format!(
|
||||
"- MCP servers from this plugin available in this session: {}.",
|
||||
available_mcp_servers
|
||||
.iter()
|
||||
.map(|server| format!("`{server}`"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
if !available_apps.is_empty() {
|
||||
lines.push(format!(
|
||||
"- Apps from this plugin available in this session: {}.",
|
||||
available_apps
|
||||
.iter()
|
||||
.map(|app| format!("`{app}`"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
if lines.len() == 1 {
|
||||
return None;
|
||||
}
|
||||
|
||||
lines.push("Use these plugin-associated capabilities to help solve the task.".to_string());
|
||||
|
||||
Some(lines.join("\n"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -58,13 +58,11 @@ fn render_js_repl_instructions(config: &Config) -> Option<String> {
|
||||
"- Helpers: `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n",
|
||||
);
|
||||
section.push_str("- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n");
|
||||
section.push_str("- `codex.emitImage(...)` adds exactly one image to the outer `js_repl` function output. It accepts a direct image URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n");
|
||||
section.push_str("- `codex.emitImage(...)` adds one image to the outer `js_repl` function output each time you call it, so you can call it multiple times to emit multiple images. It accepts a data URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n");
|
||||
section.push_str("- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\" })`.\n");
|
||||
section.push_str("- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\" }))`.\n");
|
||||
if config.features.enabled(Feature::ImageDetailOriginal) {
|
||||
section.push_str("- When generating or converting images for `view_image` in `js_repl`, prefer JPEG at 85% quality unless lossless quality is strictly required; other formats can be used if the user requests them. This keeps uploads smaller and reduces the chance of hitting image size caps.\n");
|
||||
}
|
||||
section.push_str("- Top-level bindings persist across cells. If you hit `SyntaxError: Identifier 'x' has already been declared`, reuse the binding, pick a new name, wrap in `{ ... }` for block scope, or reset the kernel with `js_repl_reset`.\n");
|
||||
section.push_str("- When encoding an image to send with `codex.emitImage(...)` or `view_image`, prefer JPEG at about 85 quality when lossy compression is acceptable; use PNG when transparency or lossless detail matters. Smaller uploads are faster and less likely to hit size limits.\n");
|
||||
section.push_str("- Top-level bindings persist across cells. If a cell throws, prior bindings remain available and bindings that finished initializing before the throw often remain usable in later cells. For code you plan to reuse across cells, prefer declaring or assigning it in direct top-level statements before operations that might throw. If you hit `SyntaxError: Identifier 'x' has already been declared`, reuse the binding, pick a new name, wrap in `{ ... }` for block scope, or reset the kernel with `js_repl_reset`.\n");
|
||||
section.push_str("- Top-level static import declarations (for example `import x from \"./file.js\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")`, `await import(\"./file.js\")`, or `await import(\"/abs/path/file.mjs\")` instead. Imported local files must be ESM `.js`/`.mjs` files and run in the same REPL VM context. Bare package imports always resolve from REPL-global search roots (`CODEX_JS_REPL_NODE_MODULE_DIRS`, then cwd), not relative to the imported file location. Local files may statically import only other local relative/absolute/`file://` `.js`/`.mjs` files; package and builtin imports from local files must stay dynamic. `import.meta.resolve()` returns importable strings such as `file://...`, bare package names, and `node:...` specifiers. Local file modules reload between execs, while top-level bindings persist until `js_repl_reset`.\n");
|
||||
|
||||
if config.features.enabled(Feature::JsReplToolsOnly) {
|
||||
@@ -492,7 +490,7 @@ mod tests {
|
||||
let res = get_user_instructions(&cfg, None, None)
|
||||
.await
|
||||
.expect("js_repl instructions expected");
|
||||
let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n- `codex.emitImage(...)` adds exactly one image to the outer `js_repl` function output. It accepts a direct image URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\" })`.\n- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\" }))`.\n- Top-level bindings persist across cells. If you hit `SyntaxError: Identifier 'x' has already been declared`, reuse the binding, pick a new name, wrap in `{ ... }` for block scope, or reset the kernel with `js_repl_reset`.\n- Top-level static import declarations (for example `import x from \"./file.js\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")`, `await import(\"./file.js\")`, or `await import(\"/abs/path/file.mjs\")` instead. Imported local files must be ESM `.js`/`.mjs` files and run in the same REPL VM context. Bare package imports always resolve from REPL-global search roots (`CODEX_JS_REPL_NODE_MODULE_DIRS`, then cwd), not relative to the imported file location. Local files may statically import only other local relative/absolute/`file://` `.js`/`.mjs` files; package and builtin imports from local files must stay dynamic. `import.meta.resolve()` returns importable strings such as `file://...`, bare package names, and `node:...` specifiers. Local file modules reload between execs, while top-level bindings persist until `js_repl_reset`.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.tool(...)`, and `codex.emitImage(...)`.";
|
||||
let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n- `codex.emitImage(...)` adds one image to the outer `js_repl` function output each time you call it, so you can call it multiple times to emit multiple images. It accepts a data URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\" })`.\n- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\" }))`.\n- When encoding an image to send with `codex.emitImage(...)` or `view_image`, prefer JPEG at about 85 quality when lossy compression is acceptable; use PNG when transparency or lossless detail matters. Smaller uploads are faster and less likely to hit size limits.\n- Top-level bindings persist across cells. If a cell throws, prior bindings remain available and bindings that finished initializing before the throw often remain usable in later cells. For code you plan to reuse across cells, prefer declaring or assigning it in direct top-level statements before operations that might throw. If you hit `SyntaxError: Identifier 'x' has already been declared`, reuse the binding, pick a new name, wrap in `{ ... }` for block scope, or reset the kernel with `js_repl_reset`.\n- Top-level static import declarations (for example `import x from \"./file.js\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")`, `await import(\"./file.js\")`, or `await import(\"/abs/path/file.mjs\")` instead. Imported local files must be ESM `.js`/`.mjs` files and run in the same REPL VM context. Bare package imports always resolve from REPL-global search roots (`CODEX_JS_REPL_NODE_MODULE_DIRS`, then cwd), not relative to the imported file location. Local files may statically import only other local relative/absolute/`file://` `.js`/`.mjs` files; package and builtin imports from local files must stay dynamic. `import.meta.resolve()` returns importable strings such as `file://...`, bare package names, and `node:...` specifiers. Local file modules reload between execs, while top-level bindings persist until `js_repl_reset`.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.tool(...)`, and `codex.emitImage(...)`.";
|
||||
assert_eq!(res, expected);
|
||||
}
|
||||
|
||||
@@ -511,12 +509,12 @@ mod tests {
|
||||
let res = get_user_instructions(&cfg, None, None)
|
||||
.await
|
||||
.expect("js_repl instructions expected");
|
||||
let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n- `codex.emitImage(...)` adds exactly one image to the outer `js_repl` function output. It accepts a direct image URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\" })`.\n- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\" }))`.\n- Top-level bindings persist across cells. If you hit `SyntaxError: Identifier 'x' has already been declared`, reuse the binding, pick a new name, wrap in `{ ... }` for block scope, or reset the kernel with `js_repl_reset`.\n- Top-level static import declarations (for example `import x from \"./file.js\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")`, `await import(\"./file.js\")`, or `await import(\"/abs/path/file.mjs\")` instead. Imported local files must be ESM `.js`/`.mjs` files and run in the same REPL VM context. Bare package imports always resolve from REPL-global search roots (`CODEX_JS_REPL_NODE_MODULE_DIRS`, then cwd), not relative to the imported file location. Local files may statically import only other local relative/absolute/`file://` `.js`/`.mjs` files; package and builtin imports from local files must stay dynamic. `import.meta.resolve()` returns importable strings such as `file://...`, bare package names, and `node:...` specifiers. Local file modules reload between execs, while top-level bindings persist until `js_repl_reset`.\n- Do not call tools directly; use `js_repl` + `codex.tool(...)` for all tool calls, including shell commands.\n- MCP tools (if any) can also be called by name via `codex.tool(...)`.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.tool(...)`, and `codex.emitImage(...)`.";
|
||||
let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n- `codex.emitImage(...)` adds one image to the outer `js_repl` function output each time you call it, so you can call it multiple times to emit multiple images. It accepts a data URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\" })`.\n- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\" }))`.\n- When encoding an image to send with `codex.emitImage(...)` or `view_image`, prefer JPEG at about 85 quality when lossy compression is acceptable; use PNG when transparency or lossless detail matters. Smaller uploads are faster and less likely to hit size limits.\n- Top-level bindings persist across cells. If a cell throws, prior bindings remain available and bindings that finished initializing before the throw often remain usable in later cells. For code you plan to reuse across cells, prefer declaring or assigning it in direct top-level statements before operations that might throw. If you hit `SyntaxError: Identifier 'x' has already been declared`, reuse the binding, pick a new name, wrap in `{ ... }` for block scope, or reset the kernel with `js_repl_reset`.\n- Top-level static import declarations (for example `import x from \"./file.js\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")`, `await import(\"./file.js\")`, or `await import(\"/abs/path/file.mjs\")` instead. Imported local files must be ESM `.js`/`.mjs` files and run in the same REPL VM context. Bare package imports always resolve from REPL-global search roots (`CODEX_JS_REPL_NODE_MODULE_DIRS`, then cwd), not relative to the imported file location. Local files may statically import only other local relative/absolute/`file://` `.js`/`.mjs` files; package and builtin imports from local files must stay dynamic. `import.meta.resolve()` returns importable strings such as `file://...`, bare package names, and `node:...` specifiers. Local file modules reload between execs, while top-level bindings persist until `js_repl_reset`.\n- Do not call tools directly; use `js_repl` + `codex.tool(...)` for all tool calls, including shell commands.\n- MCP tools (if any) can also be called by name via `codex.tool(...)`.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.tool(...)`, and `codex.emitImage(...)`.";
|
||||
assert_eq!(res, expected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn js_repl_original_resolution_guidance_is_feature_gated() {
|
||||
async fn js_repl_image_detail_original_does_not_change_instructions() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let mut cfg = make_config(&tmp, 4096, None).await;
|
||||
let mut features = cfg.features.get().clone();
|
||||
@@ -530,7 +528,7 @@ mod tests {
|
||||
let res = get_user_instructions(&cfg, None, None)
|
||||
.await
|
||||
.expect("js_repl instructions expected");
|
||||
let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n- `codex.emitImage(...)` adds exactly one image to the outer `js_repl` function output. It accepts a direct image URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\" })`.\n- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\" }))`.\n- When generating or converting images for `view_image` in `js_repl`, prefer JPEG at 85% quality unless lossless quality is strictly required; other formats can be used if the user requests them. This keeps uploads smaller and reduces the chance of hitting image size caps.\n- Top-level bindings persist across cells. If you hit `SyntaxError: Identifier 'x' has already been declared`, reuse the binding, pick a new name, wrap in `{ ... }` for block scope, or reset the kernel with `js_repl_reset`.\n- Top-level static import declarations (for example `import x from \"./file.js\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")`, `await import(\"./file.js\")`, or `await import(\"/abs/path/file.mjs\")` instead. Imported local files must be ESM `.js`/`.mjs` files and run in the same REPL VM context. Bare package imports always resolve from REPL-global search roots (`CODEX_JS_REPL_NODE_MODULE_DIRS`, then cwd), not relative to the imported file location. Local files may statically import only other local relative/absolute/`file://` `.js`/`.mjs` files; package and builtin imports from local files must stay dynamic. `import.meta.resolve()` returns importable strings such as `file://...`, bare package names, and `node:...` specifiers. Local file modules reload between execs, while top-level bindings persist until `js_repl_reset`.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.tool(...)`, and `codex.emitImage(...)`.";
|
||||
let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n- `codex.emitImage(...)` adds one image to the outer `js_repl` function output each time you call it, so you can call it multiple times to emit multiple images. It accepts a data URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\" })`.\n- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\" }))`.\n- When encoding an image to send with `codex.emitImage(...)` or `view_image`, prefer JPEG at about 85 quality when lossy compression is acceptable; use PNG when transparency or lossless detail matters. Smaller uploads are faster and less likely to hit size limits.\n- Top-level bindings persist across cells. If a cell throws, prior bindings remain available and bindings that finished initializing before the throw often remain usable in later cells. For code you plan to reuse across cells, prefer declaring or assigning it in direct top-level statements before operations that might throw. If you hit `SyntaxError: Identifier 'x' has already been declared`, reuse the binding, pick a new name, wrap in `{ ... }` for block scope, or reset the kernel with `js_repl_reset`.\n- Top-level static import declarations (for example `import x from \"./file.js\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")`, `await import(\"./file.js\")`, or `await import(\"/abs/path/file.mjs\")` instead. Imported local files must be ESM `.js`/`.mjs` files and run in the same REPL VM context. Bare package imports always resolve from REPL-global search roots (`CODEX_JS_REPL_NODE_MODULE_DIRS`, then cwd), not relative to the imported file location. Local files may statically import only other local relative/absolute/`file://` `.js`/`.mjs` files; package and builtin imports from local files must stay dynamic. `import.meta.resolve()` returns importable strings such as `file://...`, bare package names, and `node:...` specifiers. Local file modules reload between execs, while top-level bindings persist until `js_repl_reset`.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.tool(...)`, and `codex.emitImage(...)`.";
|
||||
assert_eq!(res, expected);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
use crate::codex::Session;
|
||||
use crate::git_info::resolve_root_git_project_for_trust;
|
||||
use crate::truncate::TruncationPolicy;
|
||||
use crate::truncate::truncate_text;
|
||||
use chrono::Utc;
|
||||
use codex_state::SortKey;
|
||||
use codex_state::ThreadMetadata;
|
||||
use dirs::home_dir;
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::ffi::OsStr;
|
||||
use std::fs::DirEntry;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tracing::debug;
|
||||
use tracing::info;
|
||||
use tracing::warn;
|
||||
|
||||
const STARTUP_CONTEXT_HEADER: &str = "Startup context from Codex.\nThis is background context about recent work and machine/workspace layout. It may be incomplete or stale. Use it to inform responses, and do not repeat it back unless relevant.";
|
||||
const RECENT_WORK_SECTION_TOKEN_BUDGET: usize = 2_200;
|
||||
const WORKSPACE_SECTION_TOKEN_BUDGET: usize = 1_600;
|
||||
const NOTES_SECTION_TOKEN_BUDGET: usize = 300;
|
||||
const MAX_RECENT_THREADS: usize = 40;
|
||||
const MAX_RECENT_WORK_GROUPS: usize = 8;
|
||||
const MAX_CURRENT_CWD_ASKS: usize = 8;
|
||||
const MAX_OTHER_CWD_ASKS: usize = 5;
|
||||
const MAX_ASK_CHARS: usize = 240;
|
||||
const TREE_MAX_DEPTH: usize = 2;
|
||||
const DIR_ENTRY_LIMIT: usize = 20;
|
||||
const APPROX_BYTES_PER_TOKEN: usize = 4;
|
||||
const NOISY_DIR_NAMES: &[&str] = &[
|
||||
".git",
|
||||
".next",
|
||||
".pytest_cache",
|
||||
".ruff_cache",
|
||||
"__pycache__",
|
||||
"build",
|
||||
"dist",
|
||||
"node_modules",
|
||||
"out",
|
||||
"target",
|
||||
];
|
||||
|
||||
pub(crate) async fn build_realtime_startup_context(
|
||||
sess: &Session,
|
||||
budget_tokens: usize,
|
||||
) -> Option<String> {
|
||||
let config = sess.get_config().await;
|
||||
let cwd = config.cwd.clone();
|
||||
let recent_threads = load_recent_threads(sess).await;
|
||||
let recent_work_section = build_recent_work_section(&cwd, &recent_threads);
|
||||
let workspace_section = build_workspace_section(&cwd);
|
||||
|
||||
if recent_work_section.is_none() && workspace_section.is_none() {
|
||||
debug!("realtime startup context unavailable; skipping injection");
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut parts = vec![STARTUP_CONTEXT_HEADER.to_string()];
|
||||
|
||||
let has_recent_work_section = recent_work_section.is_some();
|
||||
let has_workspace_section = workspace_section.is_some();
|
||||
|
||||
if let Some(section) = format_section(
|
||||
"Recent Work",
|
||||
recent_work_section,
|
||||
RECENT_WORK_SECTION_TOKEN_BUDGET,
|
||||
) {
|
||||
parts.push(section);
|
||||
}
|
||||
if let Some(section) = format_section(
|
||||
"Machine / Workspace Map",
|
||||
workspace_section,
|
||||
WORKSPACE_SECTION_TOKEN_BUDGET,
|
||||
) {
|
||||
parts.push(section);
|
||||
}
|
||||
if let Some(section) = format_section(
|
||||
"Notes",
|
||||
Some("Built at realtime startup from persisted thread metadata in the state DB and a bounded local workspace scan. This excludes repo memory instructions, AGENTS files, project-doc prompt blends, and memory summaries.".to_string()),
|
||||
NOTES_SECTION_TOKEN_BUDGET,
|
||||
) {
|
||||
parts.push(section);
|
||||
}
|
||||
|
||||
let context = truncate_text(&parts.join("\n\n"), TruncationPolicy::Tokens(budget_tokens));
|
||||
debug!(
|
||||
approx_tokens = approx_token_count(&context),
|
||||
bytes = context.len(),
|
||||
has_recent_work_section,
|
||||
has_workspace_section,
|
||||
"built realtime startup context"
|
||||
);
|
||||
info!("realtime startup context: {context}");
|
||||
Some(context)
|
||||
}
|
||||
|
||||
async fn load_recent_threads(sess: &Session) -> Vec<ThreadMetadata> {
|
||||
let Some(state_db) = sess.services.state_db.as_ref() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
match state_db
|
||||
.list_threads(
|
||||
MAX_RECENT_THREADS,
|
||||
None,
|
||||
SortKey::UpdatedAt,
|
||||
&[],
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(page) => page.items,
|
||||
Err(err) => {
|
||||
warn!("failed to load realtime startup threads from state db: {err}");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_recent_work_section(cwd: &Path, recent_threads: &[ThreadMetadata]) -> Option<String> {
|
||||
let mut groups: HashMap<PathBuf, Vec<&ThreadMetadata>> = HashMap::new();
|
||||
for entry in recent_threads {
|
||||
let group =
|
||||
resolve_root_git_project_for_trust(&entry.cwd).unwrap_or_else(|| entry.cwd.clone());
|
||||
groups.entry(group).or_default().push(entry);
|
||||
}
|
||||
|
||||
let current_group =
|
||||
resolve_root_git_project_for_trust(cwd).unwrap_or_else(|| cwd.to_path_buf());
|
||||
let mut groups = groups.into_iter().collect::<Vec<_>>();
|
||||
groups.sort_by(|(left_group, left_entries), (right_group, right_entries)| {
|
||||
let left_latest = left_entries
|
||||
.iter()
|
||||
.map(|entry| entry.updated_at)
|
||||
.max()
|
||||
.unwrap_or_else(Utc::now);
|
||||
let right_latest = right_entries
|
||||
.iter()
|
||||
.map(|entry| entry.updated_at)
|
||||
.max()
|
||||
.unwrap_or_else(Utc::now);
|
||||
(
|
||||
*left_group != current_group,
|
||||
Reverse(left_latest),
|
||||
left_group.as_os_str(),
|
||||
)
|
||||
.cmp(&(
|
||||
*right_group != current_group,
|
||||
Reverse(right_latest),
|
||||
right_group.as_os_str(),
|
||||
))
|
||||
});
|
||||
|
||||
let sections = groups
|
||||
.into_iter()
|
||||
.take(MAX_RECENT_WORK_GROUPS)
|
||||
.filter_map(|(group, mut entries)| {
|
||||
entries.sort_by_key(|entry| Reverse(entry.updated_at));
|
||||
format_thread_group(¤t_group, &group, entries)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
(!sections.is_empty()).then(|| sections.join("\n\n"))
|
||||
}
|
||||
|
||||
fn build_workspace_section(cwd: &Path) -> Option<String> {
|
||||
build_workspace_section_with_user_root(cwd, home_dir())
|
||||
}
|
||||
|
||||
fn build_workspace_section_with_user_root(
|
||||
cwd: &Path,
|
||||
user_root: Option<PathBuf>,
|
||||
) -> Option<String> {
|
||||
let git_root = resolve_root_git_project_for_trust(cwd);
|
||||
let cwd_tree = render_tree(cwd);
|
||||
let git_root_tree = git_root
|
||||
.as_ref()
|
||||
.filter(|git_root| git_root.as_path() != cwd)
|
||||
.and_then(|git_root| render_tree(git_root));
|
||||
let user_root_tree = user_root
|
||||
.as_ref()
|
||||
.filter(|user_root| user_root.as_path() != cwd)
|
||||
.filter(|user_root| {
|
||||
git_root
|
||||
.as_ref()
|
||||
.is_none_or(|git_root| git_root.as_path() != user_root.as_path())
|
||||
})
|
||||
.and_then(|user_root| render_tree(user_root));
|
||||
|
||||
if cwd_tree.is_none() && git_root.is_none() && user_root_tree.is_none() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut lines = vec![
|
||||
format!("Current working directory: {}", cwd.display()),
|
||||
format!("Working directory name: {}", display_name(cwd)),
|
||||
];
|
||||
|
||||
if let Some(git_root) = &git_root {
|
||||
lines.push(format!("Git root: {}", git_root.display()));
|
||||
lines.push(format!("Git project: {}", display_name(git_root)));
|
||||
}
|
||||
if let Some(user_root) = &user_root {
|
||||
lines.push(format!("User root: {}", user_root.display()));
|
||||
}
|
||||
|
||||
if let Some(tree) = cwd_tree {
|
||||
lines.push(String::new());
|
||||
lines.push("Working directory tree:".to_string());
|
||||
lines.extend(tree);
|
||||
}
|
||||
|
||||
if let Some(tree) = git_root_tree {
|
||||
lines.push(String::new());
|
||||
lines.push("Git root tree:".to_string());
|
||||
lines.extend(tree);
|
||||
}
|
||||
|
||||
if let Some(tree) = user_root_tree {
|
||||
lines.push(String::new());
|
||||
lines.push("User root tree:".to_string());
|
||||
lines.extend(tree);
|
||||
}
|
||||
|
||||
Some(lines.join("\n"))
|
||||
}
|
||||
|
||||
fn render_tree(root: &Path) -> Option<Vec<String>> {
|
||||
if !root.is_dir() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut lines = Vec::new();
|
||||
collect_tree_lines(root, 0, &mut lines);
|
||||
(!lines.is_empty()).then_some(lines)
|
||||
}
|
||||
|
||||
fn collect_tree_lines(dir: &Path, depth: usize, lines: &mut Vec<String>) {
|
||||
if depth >= TREE_MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
|
||||
let entries = match read_sorted_entries(dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(_) => return,
|
||||
};
|
||||
let total_entries = entries.len();
|
||||
|
||||
for entry in entries.into_iter().take(DIR_ENTRY_LIMIT) {
|
||||
let Ok(file_type) = entry.file_type() else {
|
||||
continue;
|
||||
};
|
||||
let name = file_name_string(&entry.path());
|
||||
let indent = " ".repeat(depth);
|
||||
let suffix = if file_type.is_dir() { "/" } else { "" };
|
||||
lines.push(format!("{indent}- {name}{suffix}"));
|
||||
if file_type.is_dir() {
|
||||
collect_tree_lines(&entry.path(), depth + 1, lines);
|
||||
}
|
||||
}
|
||||
|
||||
if total_entries > DIR_ENTRY_LIMIT {
|
||||
lines.push(format!(
|
||||
"{}- ... {} more entries",
|
||||
" ".repeat(depth),
|
||||
total_entries - DIR_ENTRY_LIMIT
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn read_sorted_entries(dir: &Path) -> io::Result<Vec<DirEntry>> {
|
||||
let mut entries = std::fs::read_dir(dir)?
|
||||
.filter_map(Result::ok)
|
||||
.filter(|entry| !is_noisy_name(&entry.file_name()))
|
||||
.collect::<Vec<_>>();
|
||||
entries.sort_by(|left, right| {
|
||||
let left_is_dir = left
|
||||
.file_type()
|
||||
.map(|file_type| file_type.is_dir())
|
||||
.unwrap_or(false);
|
||||
let right_is_dir = right
|
||||
.file_type()
|
||||
.map(|file_type| file_type.is_dir())
|
||||
.unwrap_or(false);
|
||||
(!left_is_dir, file_name_string(&left.path()))
|
||||
.cmp(&(!right_is_dir, file_name_string(&right.path())))
|
||||
});
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn is_noisy_name(name: &OsStr) -> bool {
|
||||
let name = name.to_string_lossy();
|
||||
name.starts_with('.') || NOISY_DIR_NAMES.iter().any(|noisy| *noisy == name)
|
||||
}
|
||||
|
||||
fn format_section(title: &str, body: Option<String>, budget_tokens: usize) -> Option<String> {
|
||||
let body = body?;
|
||||
let body = body.trim();
|
||||
if body.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(format!(
|
||||
"## {title}\n{}",
|
||||
truncate_text(body, TruncationPolicy::Tokens(budget_tokens))
|
||||
))
|
||||
}
|
||||
|
||||
fn format_thread_group(
|
||||
current_group: &Path,
|
||||
group: &Path,
|
||||
entries: Vec<&ThreadMetadata>,
|
||||
) -> Option<String> {
|
||||
let latest = entries.first()?;
|
||||
let group_label = if resolve_root_git_project_for_trust(latest.cwd.as_path()).is_some() {
|
||||
format!("### Git repo: {}", group.display())
|
||||
} else {
|
||||
format!("### Directory: {}", group.display())
|
||||
};
|
||||
let mut lines = vec![
|
||||
group_label,
|
||||
format!("Recent sessions: {}", entries.len()),
|
||||
format!("Latest activity: {}", latest.updated_at.to_rfc3339()),
|
||||
];
|
||||
|
||||
if let Some(git_branch) = latest
|
||||
.git_branch
|
||||
.as_deref()
|
||||
.filter(|git_branch| !git_branch.is_empty())
|
||||
{
|
||||
lines.push(format!("Latest branch: {git_branch}"));
|
||||
}
|
||||
|
||||
lines.push(String::new());
|
||||
lines.push("User asks:".to_string());
|
||||
|
||||
let mut seen = HashSet::new();
|
||||
let max_asks = if group == current_group {
|
||||
MAX_CURRENT_CWD_ASKS
|
||||
} else {
|
||||
MAX_OTHER_CWD_ASKS
|
||||
};
|
||||
|
||||
for entry in entries {
|
||||
let Some(first_user_message) = entry.first_user_message.as_deref() else {
|
||||
continue;
|
||||
};
|
||||
let ask = first_user_message
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let dedupe_key = format!("{}:{ask}", entry.cwd.display());
|
||||
if ask.is_empty() || !seen.insert(dedupe_key) {
|
||||
continue;
|
||||
}
|
||||
let ask = if ask.chars().count() > MAX_ASK_CHARS {
|
||||
format!(
|
||||
"{}...",
|
||||
ask.chars()
|
||||
.take(MAX_ASK_CHARS.saturating_sub(3))
|
||||
.collect::<String>()
|
||||
)
|
||||
} else {
|
||||
ask
|
||||
};
|
||||
lines.push(format!("- {}: {ask}", entry.cwd.display()));
|
||||
if seen.len() == max_asks {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
(lines.len() > 5).then(|| lines.join("\n"))
|
||||
}
|
||||
|
||||
fn display_name(path: &Path) -> String {
|
||||
path.file_name()
|
||||
.and_then(OsStr::to_str)
|
||||
.map(str::to_owned)
|
||||
.unwrap_or_else(|| path.display().to_string())
|
||||
}
|
||||
|
||||
fn file_name_string(path: &Path) -> String {
|
||||
path.file_name()
|
||||
.and_then(OsStr::to_str)
|
||||
.map(str::to_owned)
|
||||
.unwrap_or_else(|| path.display().to_string())
|
||||
}
|
||||
|
||||
fn approx_token_count(text: &str) -> usize {
|
||||
text.len().div_ceil(APPROX_BYTES_PER_TOKEN)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::build_recent_work_section;
|
||||
use super::build_workspace_section;
|
||||
use super::build_workspace_section_with_user_root;
|
||||
use chrono::TimeZone;
|
||||
use chrono::Utc;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_state::ThreadMetadata;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn thread_metadata(cwd: &str, title: &str, first_user_message: &str) -> ThreadMetadata {
|
||||
ThreadMetadata {
|
||||
id: ThreadId::new(),
|
||||
rollout_path: PathBuf::from("/tmp/rollout.jsonl"),
|
||||
created_at: Utc
|
||||
.timestamp_opt(1_709_251_100, 0)
|
||||
.single()
|
||||
.expect("valid timestamp"),
|
||||
updated_at: Utc
|
||||
.timestamp_opt(1_709_251_200, 0)
|
||||
.single()
|
||||
.expect("valid timestamp"),
|
||||
source: "cli".to_string(),
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
model_provider: "test-provider".to_string(),
|
||||
cwd: PathBuf::from(cwd),
|
||||
cli_version: "test".to_string(),
|
||||
title: title.to_string(),
|
||||
sandbox_policy: "workspace-write".to_string(),
|
||||
approval_mode: "never".to_string(),
|
||||
tokens_used: 0,
|
||||
first_user_message: Some(first_user_message.to_string()),
|
||||
archived_at: None,
|
||||
git_sha: None,
|
||||
git_branch: Some("main".to_string()),
|
||||
git_origin_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_section_requires_meaningful_structure() {
|
||||
let cwd = TempDir::new().expect("tempdir");
|
||||
assert_eq!(
|
||||
build_workspace_section_with_user_root(cwd.path(), None),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_section_includes_tree_when_entries_exist() {
|
||||
let cwd = TempDir::new().expect("tempdir");
|
||||
fs::create_dir(cwd.path().join("docs")).expect("create docs dir");
|
||||
fs::write(cwd.path().join("README.md"), "hello").expect("write readme");
|
||||
|
||||
let section = build_workspace_section(cwd.path()).expect("workspace section");
|
||||
assert!(section.contains("Working directory tree:"));
|
||||
assert!(section.contains("- docs/"));
|
||||
assert!(section.contains("- README.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_section_includes_user_root_tree_when_distinct() {
|
||||
let root = TempDir::new().expect("tempdir");
|
||||
let cwd = root.path().join("cwd");
|
||||
let git_root = root.path().join("git");
|
||||
let user_root = root.path().join("home");
|
||||
|
||||
fs::create_dir_all(cwd.join("docs")).expect("create cwd docs dir");
|
||||
fs::write(cwd.join("README.md"), "hello").expect("write cwd readme");
|
||||
fs::create_dir_all(git_root.join(".git")).expect("create git dir");
|
||||
fs::write(git_root.join("Cargo.toml"), "[workspace]").expect("write git root marker");
|
||||
fs::create_dir_all(user_root.join("code")).expect("create user root child");
|
||||
fs::write(user_root.join(".zshrc"), "export TEST=1").expect("write home file");
|
||||
|
||||
let section = build_workspace_section_with_user_root(cwd.as_path(), Some(user_root))
|
||||
.expect("workspace section");
|
||||
assert!(section.contains("User root tree:"));
|
||||
assert!(section.contains("- code/"));
|
||||
assert!(!section.contains("- .zshrc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_work_section_groups_threads_by_cwd() {
|
||||
let root = TempDir::new().expect("tempdir");
|
||||
let repo = root.path().join("repo");
|
||||
let workspace_a = repo.join("workspace-a");
|
||||
let workspace_b = repo.join("workspace-b");
|
||||
let outside = root.path().join("outside");
|
||||
|
||||
fs::create_dir(&repo).expect("create repo dir");
|
||||
Command::new("git")
|
||||
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
||||
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||||
.args(["init"])
|
||||
.current_dir(&repo)
|
||||
.output()
|
||||
.expect("git init");
|
||||
fs::create_dir_all(&workspace_a).expect("create workspace a");
|
||||
fs::create_dir_all(&workspace_b).expect("create workspace b");
|
||||
fs::create_dir_all(&outside).expect("create outside dir");
|
||||
|
||||
let recent_threads = vec![
|
||||
thread_metadata(
|
||||
workspace_a.to_string_lossy().as_ref(),
|
||||
"Investigate realtime startup context",
|
||||
"Log the startup context before sending it",
|
||||
),
|
||||
thread_metadata(
|
||||
workspace_b.to_string_lossy().as_ref(),
|
||||
"Trim websocket startup payload",
|
||||
"Remove memories from the realtime startup context",
|
||||
),
|
||||
thread_metadata(outside.to_string_lossy().as_ref(), "", "Inspect flaky test"),
|
||||
];
|
||||
let current_cwd = workspace_a;
|
||||
let repo = fs::canonicalize(repo).expect("canonicalize repo");
|
||||
|
||||
let section = build_recent_work_section(current_cwd.as_path(), &recent_threads)
|
||||
.expect("recent work section");
|
||||
assert!(section.contains(&format!("### Git repo: {}", repo.display())));
|
||||
assert!(section.contains("Recent sessions: 2"));
|
||||
assert!(section.contains("User asks:"));
|
||||
assert!(section.contains(&format!(
|
||||
"- {}: Log the startup context before sending it",
|
||||
current_cwd.display()
|
||||
)));
|
||||
assert!(section.contains(&format!("### Directory: {}", outside.display())));
|
||||
assert!(section.contains(&format!("- {}: Inspect flaky test", outside.display())));
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ use crate::codex::Session;
|
||||
use crate::default_client::default_headers;
|
||||
use crate::error::CodexErr;
|
||||
use crate::error::Result as CodexResult;
|
||||
use crate::realtime_context::build_realtime_startup_context;
|
||||
use async_channel::Receiver;
|
||||
use async_channel::Sender;
|
||||
use async_channel::TrySendError;
|
||||
@@ -43,6 +44,7 @@ const AUDIO_IN_QUEUE_CAPACITY: usize = 256;
|
||||
const USER_TEXT_IN_QUEUE_CAPACITY: usize = 64;
|
||||
const HANDOFF_OUT_QUEUE_CAPACITY: usize = 64;
|
||||
const OUTPUT_EVENTS_QUEUE_CAPACITY: usize = 256;
|
||||
const REALTIME_STARTUP_CONTEXT_TOKEN_BUDGET: usize = 5_000;
|
||||
|
||||
pub(crate) struct RealtimeConversationManager {
|
||||
state: Mutex<Option<ConversationState>>,
|
||||
@@ -282,6 +284,13 @@ pub(crate) async fn handle_start(
|
||||
.experimental_realtime_ws_backend_prompt
|
||||
.clone()
|
||||
.unwrap_or(params.prompt);
|
||||
let prompt =
|
||||
match build_realtime_startup_context(sess.as_ref(), REALTIME_STARTUP_CONTEXT_TOKEN_BUDGET)
|
||||
.await
|
||||
{
|
||||
Some(context) => format!("{prompt}\n\n{context}"),
|
||||
None => prompt,
|
||||
};
|
||||
let model = config.experimental_realtime_ws_model.clone();
|
||||
|
||||
let requested_session_id = params
|
||||
|
||||
@@ -1404,6 +1404,7 @@ mod tests {
|
||||
timestamp: "2025-01-03T13:00:01Z".to_string(),
|
||||
item: RolloutItem::TurnContext(TurnContextItem {
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
trace_id: None,
|
||||
cwd: latest_cwd.clone(),
|
||||
current_date: None,
|
||||
timezone: None,
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use codex_protocol::models::MacOsAutomationPermission;
|
||||
use codex_protocol::models::MacOsPreferencesPermission;
|
||||
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
|
||||
|
||||
/// Merges macOS seatbelt profile extensions by taking the permissive union of
|
||||
/// each permission field.
|
||||
pub(crate) fn merge_macos_seatbelt_profile_extensions(
|
||||
base: Option<&MacOsSeatbeltProfileExtensions>,
|
||||
permissions: Option<&MacOsSeatbeltProfileExtensions>,
|
||||
) -> Option<MacOsSeatbeltProfileExtensions> {
|
||||
let Some(permissions) = permissions else {
|
||||
return base.cloned();
|
||||
};
|
||||
|
||||
match base {
|
||||
Some(base) => Some(MacOsSeatbeltProfileExtensions {
|
||||
macos_preferences: union_macos_preferences_permission(
|
||||
&base.macos_preferences,
|
||||
&permissions.macos_preferences,
|
||||
),
|
||||
macos_automation: union_macos_automation_permission(
|
||||
&base.macos_automation,
|
||||
&permissions.macos_automation,
|
||||
),
|
||||
macos_accessibility: base.macos_accessibility || permissions.macos_accessibility,
|
||||
macos_calendar: base.macos_calendar || permissions.macos_calendar,
|
||||
}),
|
||||
None => Some(permissions.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Unions two preferences permissions by keeping the more permissive one.
|
||||
///
|
||||
/// The larger rank wins: `None < ReadOnly < ReadWrite`. When both sides have
|
||||
/// the same rank, this keeps `base`.
|
||||
fn union_macos_preferences_permission(
|
||||
base: &MacOsPreferencesPermission,
|
||||
requested: &MacOsPreferencesPermission,
|
||||
) -> MacOsPreferencesPermission {
|
||||
if base < requested {
|
||||
requested.clone()
|
||||
} else {
|
||||
base.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Unions two automation permissions by keeping the more permissive result.
|
||||
///
|
||||
/// `All` wins over everything, `None` yields to the other side, and two bundle
|
||||
/// ID allowlists are unioned together.
|
||||
fn union_macos_automation_permission(
|
||||
base: &MacOsAutomationPermission,
|
||||
requested: &MacOsAutomationPermission,
|
||||
) -> MacOsAutomationPermission {
|
||||
match (base, requested) {
|
||||
(MacOsAutomationPermission::All, _) | (_, MacOsAutomationPermission::All) => {
|
||||
MacOsAutomationPermission::All
|
||||
}
|
||||
(MacOsAutomationPermission::None, _) => requested.clone(),
|
||||
(_, MacOsAutomationPermission::None) => base.clone(),
|
||||
(
|
||||
MacOsAutomationPermission::BundleIds(base_bundle_ids),
|
||||
MacOsAutomationPermission::BundleIds(requested_bundle_ids),
|
||||
) => MacOsAutomationPermission::BundleIds(
|
||||
base_bundle_ids
|
||||
.iter()
|
||||
.chain(requested_bundle_ids.iter())
|
||||
.cloned()
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "macos"))]
|
||||
mod tests {
|
||||
use super::merge_macos_seatbelt_profile_extensions;
|
||||
use super::union_macos_automation_permission;
|
||||
use super::union_macos_preferences_permission;
|
||||
use codex_protocol::models::MacOsAutomationPermission;
|
||||
use codex_protocol::models::MacOsPreferencesPermission;
|
||||
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn merge_extensions_widens_permissions() {
|
||||
let base = MacOsSeatbeltProfileExtensions {
|
||||
macos_preferences: MacOsPreferencesPermission::ReadOnly,
|
||||
macos_automation: MacOsAutomationPermission::BundleIds(vec![
|
||||
"com.apple.Calendar".to_string(),
|
||||
]),
|
||||
macos_accessibility: false,
|
||||
macos_calendar: false,
|
||||
};
|
||||
let requested = MacOsSeatbeltProfileExtensions {
|
||||
macos_preferences: MacOsPreferencesPermission::ReadWrite,
|
||||
macos_automation: MacOsAutomationPermission::BundleIds(vec![
|
||||
"com.apple.Notes".to_string(),
|
||||
"com.apple.Calendar".to_string(),
|
||||
]),
|
||||
macos_accessibility: true,
|
||||
macos_calendar: true,
|
||||
};
|
||||
|
||||
let merged =
|
||||
merge_macos_seatbelt_profile_extensions(Some(&base), Some(&requested)).expect("merge");
|
||||
|
||||
assert_eq!(
|
||||
merged,
|
||||
MacOsSeatbeltProfileExtensions {
|
||||
macos_preferences: MacOsPreferencesPermission::ReadWrite,
|
||||
macos_automation: MacOsAutomationPermission::BundleIds(vec![
|
||||
"com.apple.Calendar".to_string(),
|
||||
"com.apple.Notes".to_string(),
|
||||
]),
|
||||
macos_accessibility: true,
|
||||
macos_calendar: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn union_macos_preferences_permission_does_not_downgrade() {
|
||||
let base = MacOsPreferencesPermission::ReadWrite;
|
||||
let requested = MacOsPreferencesPermission::ReadOnly;
|
||||
|
||||
let merged = union_macos_preferences_permission(&base, &requested);
|
||||
|
||||
assert_eq!(merged, MacOsPreferencesPermission::ReadWrite);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn union_macos_automation_permission_all_is_dominant() {
|
||||
let base = MacOsAutomationPermission::BundleIds(vec!["com.apple.Notes".to_string()]);
|
||||
let requested = MacOsAutomationPermission::All;
|
||||
|
||||
let merged = union_macos_automation_permission(&base, &requested);
|
||||
|
||||
assert_eq!(merged, MacOsAutomationPermission::All);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ sandbox placement and transformation of portable CommandSpec into a
|
||||
ready‑to‑spawn environment.
|
||||
*/
|
||||
|
||||
pub(crate) mod macos_permissions;
|
||||
|
||||
use crate::exec::ExecExpiration;
|
||||
use crate::exec::ExecToolCallOutput;
|
||||
use crate::exec::SandboxType;
|
||||
@@ -25,13 +27,13 @@ use crate::tools::sandboxing::SandboxablePreference;
|
||||
use codex_network_proxy::NetworkProxy;
|
||||
use codex_protocol::config_types::WindowsSandboxLevel;
|
||||
use codex_protocol::models::FileSystemPermissions;
|
||||
#[cfg(target_os = "macos")]
|
||||
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
pub use codex_protocol::models::SandboxPermissions;
|
||||
use codex_protocol::protocol::ReadOnlyAccess;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use dunce::canonicalize;
|
||||
use macos_permissions::merge_macos_seatbelt_profile_extensions;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
@@ -98,22 +100,54 @@ pub(crate) enum SandboxTransformError {
|
||||
SeatbeltUnavailable,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct EffectiveSandboxPermissions {
|
||||
pub(crate) sandbox_policy: SandboxPolicy,
|
||||
pub(crate) macos_seatbelt_profile_extensions: Option<MacOsSeatbeltProfileExtensions>,
|
||||
}
|
||||
|
||||
impl EffectiveSandboxPermissions {
|
||||
pub(crate) fn new(
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
macos_seatbelt_profile_extensions: Option<&MacOsSeatbeltProfileExtensions>,
|
||||
additional_permissions: Option<&PermissionProfile>,
|
||||
) -> Self {
|
||||
let Some(additional_permissions) = additional_permissions else {
|
||||
return Self {
|
||||
sandbox_policy: sandbox_policy.clone(),
|
||||
macos_seatbelt_profile_extensions: macos_seatbelt_profile_extensions.cloned(),
|
||||
};
|
||||
};
|
||||
|
||||
Self {
|
||||
sandbox_policy: sandbox_policy_with_additional_permissions(
|
||||
sandbox_policy,
|
||||
additional_permissions,
|
||||
),
|
||||
macos_seatbelt_profile_extensions: merge_macos_seatbelt_profile_extensions(
|
||||
macos_seatbelt_profile_extensions,
|
||||
additional_permissions.macos.as_ref(),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_additional_permissions(
|
||||
additional_permissions: PermissionProfile,
|
||||
) -> Result<PermissionProfile, String> {
|
||||
let Some(file_system) = additional_permissions.file_system else {
|
||||
return Ok(PermissionProfile::default());
|
||||
};
|
||||
let read = file_system
|
||||
.read
|
||||
.map(|paths| normalize_permission_paths(paths, "file_system.read"));
|
||||
let write = file_system
|
||||
.write
|
||||
.map(|paths| normalize_permission_paths(paths, "file_system.write"));
|
||||
Ok(PermissionProfile {
|
||||
network: additional_permissions.network,
|
||||
file_system: Some(FileSystemPermissions { read, write }),
|
||||
..Default::default()
|
||||
file_system: additional_permissions
|
||||
.file_system
|
||||
.map(|file_system| FileSystemPermissions {
|
||||
read: file_system
|
||||
.read
|
||||
.map(|paths| normalize_permission_paths(paths, "file_system.read")),
|
||||
write: file_system
|
||||
.write
|
||||
.map(|paths| normalize_permission_paths(paths, "file_system.write")),
|
||||
}),
|
||||
macos: additional_permissions.macos,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -204,14 +238,14 @@ fn merge_network_access(
|
||||
fn sandbox_policy_with_additional_permissions(
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
additional_permissions: &PermissionProfile,
|
||||
) -> Result<SandboxPolicy, SandboxTransformError> {
|
||||
) -> SandboxPolicy {
|
||||
if additional_permissions.is_empty() {
|
||||
return Ok(sandbox_policy.clone());
|
||||
return sandbox_policy.clone();
|
||||
}
|
||||
|
||||
let (extra_reads, extra_writes) = additional_permission_roots(additional_permissions);
|
||||
|
||||
let policy = match sandbox_policy {
|
||||
match sandbox_policy {
|
||||
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => {
|
||||
sandbox_policy.clone()
|
||||
}
|
||||
@@ -260,9 +294,7 @@ fn sandbox_policy_with_additional_permissions(
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(policy)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -326,14 +358,18 @@ impl SandboxManager {
|
||||
use_linux_sandbox_bwrap,
|
||||
windows_sandbox_level,
|
||||
} = request;
|
||||
let effective_policy =
|
||||
if let Some(additional_permissions) = spec.additional_permissions.take() {
|
||||
sandbox_policy_with_additional_permissions(policy, &additional_permissions)?
|
||||
} else {
|
||||
policy.clone()
|
||||
};
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let macos_seatbelt_profile_extensions = None;
|
||||
let effective_permissions = EffectiveSandboxPermissions::new(
|
||||
policy,
|
||||
macos_seatbelt_profile_extensions,
|
||||
spec.additional_permissions.as_ref(),
|
||||
);
|
||||
let mut env = spec.env;
|
||||
if !effective_policy.has_full_network_access() {
|
||||
if !effective_permissions
|
||||
.sandbox_policy
|
||||
.has_full_network_access()
|
||||
{
|
||||
env.insert(
|
||||
CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR.to_string(),
|
||||
"1".to_string(),
|
||||
@@ -352,11 +388,13 @@ impl SandboxManager {
|
||||
seatbelt_env.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string());
|
||||
let mut args = create_seatbelt_command_args_with_extensions(
|
||||
command.clone(),
|
||||
&effective_policy,
|
||||
&effective_permissions.sandbox_policy,
|
||||
sandbox_policy_cwd,
|
||||
enforce_managed_network,
|
||||
network,
|
||||
macos_seatbelt_profile_extensions,
|
||||
effective_permissions
|
||||
.macos_seatbelt_profile_extensions
|
||||
.as_ref(),
|
||||
);
|
||||
let mut full_command = Vec::with_capacity(1 + args.len());
|
||||
full_command.push(MACOS_PATH_TO_SEATBELT_EXECUTABLE.to_string());
|
||||
@@ -371,7 +409,7 @@ impl SandboxManager {
|
||||
let allow_proxy_network = allow_network_for_proxy(enforce_managed_network);
|
||||
let mut args = create_linux_sandbox_command_args(
|
||||
command.clone(),
|
||||
&effective_policy,
|
||||
&effective_permissions.sandbox_policy,
|
||||
sandbox_policy_cwd,
|
||||
use_linux_sandbox_bwrap,
|
||||
allow_proxy_network,
|
||||
@@ -406,7 +444,7 @@ impl SandboxManager {
|
||||
sandbox,
|
||||
windows_sandbox_level,
|
||||
sandbox_permissions: spec.sandbox_permissions,
|
||||
sandbox_policy: effective_policy,
|
||||
sandbox_policy: effective_permissions.sandbox_policy,
|
||||
justification: spec.justification,
|
||||
arg0: arg0_override,
|
||||
})
|
||||
@@ -436,6 +474,8 @@ pub async fn execute_exec_request_with_after_spawn(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[cfg(target_os = "macos")]
|
||||
use super::EffectiveSandboxPermissions;
|
||||
use super::SandboxManager;
|
||||
use super::normalize_additional_permissions;
|
||||
use super::sandbox_policy_with_additional_permissions;
|
||||
@@ -445,6 +485,12 @@ mod tests {
|
||||
use crate::tools::sandboxing::SandboxablePreference;
|
||||
use codex_protocol::config_types::WindowsSandboxLevel;
|
||||
use codex_protocol::models::FileSystemPermissions;
|
||||
#[cfg(target_os = "macos")]
|
||||
use codex_protocol::models::MacOsAutomationPermission;
|
||||
#[cfg(target_os = "macos")]
|
||||
use codex_protocol::models::MacOsPreferencesPermission;
|
||||
#[cfg(target_os = "macos")]
|
||||
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
|
||||
use codex_protocol::models::NetworkPermissions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
@@ -511,6 +557,35 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn normalize_additional_permissions_preserves_macos_permissions() {
|
||||
let permissions = normalize_additional_permissions(PermissionProfile {
|
||||
macos: Some(MacOsSeatbeltProfileExtensions {
|
||||
macos_preferences: MacOsPreferencesPermission::ReadWrite,
|
||||
macos_automation: MacOsAutomationPermission::BundleIds(vec![
|
||||
"com.apple.Notes".to_string(),
|
||||
]),
|
||||
macos_accessibility: true,
|
||||
macos_calendar: true,
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
.expect("permissions");
|
||||
|
||||
assert_eq!(
|
||||
permissions.macos,
|
||||
Some(MacOsSeatbeltProfileExtensions {
|
||||
macos_preferences: MacOsPreferencesPermission::ReadWrite,
|
||||
macos_automation: MacOsAutomationPermission::BundleIds(vec![
|
||||
"com.apple.Notes".to_string(),
|
||||
]),
|
||||
macos_accessibility: true,
|
||||
macos_calendar: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_only_additional_permissions_can_enable_network_without_writes() {
|
||||
let temp_dir = TempDir::new().expect("create temp dir");
|
||||
@@ -536,8 +611,7 @@ mod tests {
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.expect("policy");
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
policy,
|
||||
@@ -550,4 +624,59 @@ mod tests {
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn effective_permissions_merge_macos_extensions_with_additional_permissions() {
|
||||
let temp_dir = TempDir::new().expect("create temp dir");
|
||||
let path = AbsolutePathBuf::from_absolute_path(
|
||||
canonicalize(temp_dir.path()).expect("canonicalize temp dir"),
|
||||
)
|
||||
.expect("absolute temp dir");
|
||||
let effective_permissions = EffectiveSandboxPermissions::new(
|
||||
&SandboxPolicy::ReadOnly {
|
||||
access: ReadOnlyAccess::Restricted {
|
||||
include_platform_defaults: true,
|
||||
readable_roots: vec![path.clone()],
|
||||
},
|
||||
network_access: false,
|
||||
},
|
||||
Some(&MacOsSeatbeltProfileExtensions {
|
||||
macos_preferences: MacOsPreferencesPermission::ReadOnly,
|
||||
macos_automation: MacOsAutomationPermission::BundleIds(vec![
|
||||
"com.apple.Calendar".to_string(),
|
||||
]),
|
||||
macos_accessibility: false,
|
||||
macos_calendar: false,
|
||||
}),
|
||||
Some(&PermissionProfile {
|
||||
file_system: Some(FileSystemPermissions {
|
||||
read: Some(vec![path]),
|
||||
write: Some(Vec::new()),
|
||||
}),
|
||||
macos: Some(MacOsSeatbeltProfileExtensions {
|
||||
macos_preferences: MacOsPreferencesPermission::ReadWrite,
|
||||
macos_automation: MacOsAutomationPermission::BundleIds(vec![
|
||||
"com.apple.Notes".to_string(),
|
||||
]),
|
||||
macos_accessibility: true,
|
||||
macos_calendar: true,
|
||||
}),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
effective_permissions.macos_seatbelt_profile_extensions,
|
||||
Some(MacOsSeatbeltProfileExtensions {
|
||||
macos_preferences: MacOsPreferencesPermission::ReadWrite,
|
||||
macos_automation: MacOsAutomationPermission::BundleIds(vec![
|
||||
"com.apple.Calendar".to_string(),
|
||||
"com.apple.Notes".to_string(),
|
||||
]),
|
||||
macos_accessibility: true,
|
||||
macos_calendar: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ impl Default for UnixDomainSocketPolicy {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct UnixSocketPathParam {
|
||||
key: String,
|
||||
index: usize,
|
||||
path: AbsolutePathBuf,
|
||||
}
|
||||
|
||||
@@ -200,17 +200,23 @@ fn unix_socket_path_params(proxy: &ProxyPolicyInputs) -> Vec<UnixSocketPathParam
|
||||
deduped_paths
|
||||
.into_values()
|
||||
.enumerate()
|
||||
.map(|(index, path)| UnixSocketPathParam {
|
||||
key: format!("UNIX_SOCKET_PATH_{index}"),
|
||||
path,
|
||||
})
|
||||
.map(|(index, path)| UnixSocketPathParam { index, path })
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn unix_socket_path_param_key(index: usize) -> String {
|
||||
format!("UNIX_SOCKET_PATH_{index}")
|
||||
}
|
||||
|
||||
fn unix_socket_dir_params(proxy: &ProxyPolicyInputs) -> Vec<(String, PathBuf)> {
|
||||
unix_socket_path_params(proxy)
|
||||
.into_iter()
|
||||
.map(|param| (param.key, param.path.into_path_buf()))
|
||||
.map(|param| {
|
||||
(
|
||||
unix_socket_path_param_key(param.index),
|
||||
param.path.into_path_buf(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -218,17 +224,39 @@ fn unix_socket_dir_params(proxy: &ProxyPolicyInputs) -> Vec<(String, PathBuf)> {
|
||||
/// When non-empty, the returned string is newline-terminated so callers can
|
||||
/// append it directly to larger policy blocks.
|
||||
fn unix_socket_policy(proxy: &ProxyPolicyInputs) -> String {
|
||||
let socket_params = unix_socket_path_params(proxy);
|
||||
let has_unix_socket_access = matches!(
|
||||
proxy.unix_domain_socket_policy,
|
||||
UnixDomainSocketPolicy::AllowAll
|
||||
) || !socket_params.is_empty();
|
||||
if !has_unix_socket_access {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let mut policy = String::new();
|
||||
policy.push_str("(allow system-socket (socket-domain AF_UNIX))\n");
|
||||
if matches!(
|
||||
proxy.unix_domain_socket_policy,
|
||||
UnixDomainSocketPolicy::AllowAll
|
||||
) {
|
||||
return "(allow network* (subpath \"/\"))\n".to_string();
|
||||
// Keep AllowAll genuinely broad here; path qualifiers look narrower
|
||||
// without a clear macOS behavioral benefit.
|
||||
policy.push_str("(allow network-bind (local unix-socket))\n");
|
||||
policy.push_str("(allow network-outbound (remote unix-socket))\n");
|
||||
return policy;
|
||||
}
|
||||
|
||||
unix_socket_path_params(proxy)
|
||||
.iter()
|
||||
.map(|param| format!("(allow network* (subpath (param \"{}\")))\n", param.key))
|
||||
.collect()
|
||||
for param in socket_params {
|
||||
let key = unix_socket_path_param_key(param.index);
|
||||
// Use subpath so allowlists cover sockets created beneath approved directories.
|
||||
policy.push_str(&format!(
|
||||
"(allow network-bind (local unix-socket (subpath (param \"{key}\"))))\n"
|
||||
));
|
||||
policy.push_str(&format!(
|
||||
"(allow network-outbound (remote unix-socket (subpath (param \"{key}\"))))\n"
|
||||
));
|
||||
}
|
||||
policy
|
||||
}
|
||||
|
||||
fn dynamic_network_policy(
|
||||
@@ -236,11 +264,12 @@ fn dynamic_network_policy(
|
||||
enforce_managed_network: bool,
|
||||
proxy: &ProxyPolicyInputs,
|
||||
) -> String {
|
||||
if !proxy.ports.is_empty() {
|
||||
let mut policy =
|
||||
String::from("; allow outbound access only to configured loopback proxy endpoints\n");
|
||||
let should_use_restricted_network_policy =
|
||||
!proxy.ports.is_empty() || proxy.has_proxy_config || enforce_managed_network;
|
||||
if should_use_restricted_network_policy {
|
||||
let mut policy = String::new();
|
||||
if proxy.allow_local_binding {
|
||||
policy.push_str("; allow localhost-only binding and loopback traffic\n");
|
||||
policy.push_str("; allow loopback local binding and loopback traffic\n");
|
||||
policy.push_str("(allow network-bind (local ip \"localhost:*\"))\n");
|
||||
policy.push_str("(allow network-inbound (local ip \"localhost:*\"))\n");
|
||||
policy.push_str("(allow network-outbound (remote ip \"localhost:*\"))\n");
|
||||
@@ -258,18 +287,6 @@ fn dynamic_network_policy(
|
||||
return format!("{policy}{MACOS_SEATBELT_NETWORK_POLICY}");
|
||||
}
|
||||
|
||||
if proxy.has_proxy_config {
|
||||
// Proxy configuration is present but we could not infer any valid loopback endpoints.
|
||||
// Fail closed to avoid silently widening network access in proxy-enforced sessions.
|
||||
return String::new();
|
||||
}
|
||||
|
||||
if enforce_managed_network {
|
||||
// Managed network requirements are active but no usable proxy endpoints
|
||||
// are available. Fail closed for network access.
|
||||
return String::new();
|
||||
}
|
||||
|
||||
if sandbox_policy.has_full_network_access() {
|
||||
// No proxy env is configured: retain the existing full-network behavior.
|
||||
format!(
|
||||
@@ -681,7 +698,7 @@ sys.exit(0 if allowed else 13)
|
||||
|
||||
assert!(
|
||||
policy.contains("(allow network-bind (local ip \"localhost:*\"))"),
|
||||
"policy should allow loopback binding when explicitly enabled:\n{policy}"
|
||||
"policy should allow loopback local binding when explicitly enabled:\n{policy}"
|
||||
);
|
||||
assert!(
|
||||
policy.contains("(allow network-inbound (local ip \"localhost:*\"))"),
|
||||
@@ -698,7 +715,7 @@ sys.exit(0 if allowed else 13)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_network_policy_fails_closed_when_proxy_config_without_ports() {
|
||||
fn dynamic_network_policy_preserves_restricted_policy_when_proxy_config_without_ports() {
|
||||
let policy = dynamic_network_policy(
|
||||
&SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![],
|
||||
@@ -716,6 +733,10 @@ sys.exit(0 if allowed else 13)
|
||||
},
|
||||
);
|
||||
|
||||
assert!(
|
||||
policy.contains("(socket-domain AF_SYSTEM)"),
|
||||
"policy should keep the restricted network profile when proxy config is present without ports:\n{policy}"
|
||||
);
|
||||
assert!(
|
||||
!policy.contains("\n(allow network-outbound)\n"),
|
||||
"policy should not include blanket outbound allowance when proxy config is present without ports:\n{policy}"
|
||||
@@ -727,7 +748,8 @@ sys.exit(0 if allowed else 13)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_network_policy_fails_closed_for_managed_network_without_proxy_config() {
|
||||
fn dynamic_network_policy_preserves_restricted_policy_for_managed_network_without_proxy_config()
|
||||
{
|
||||
let policy = dynamic_network_policy(
|
||||
&SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![],
|
||||
@@ -745,7 +767,14 @@ sys.exit(0 if allowed else 13)
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(policy, "");
|
||||
assert!(
|
||||
policy.contains("(socket-domain AF_SYSTEM)"),
|
||||
"policy should keep the restricted network profile when managed network is active without proxy endpoints:\n{policy}"
|
||||
);
|
||||
assert!(
|
||||
!policy.contains("\n(allow network-outbound)\n"),
|
||||
"policy should not include blanket outbound allowance when managed network is active without proxy endpoints:\n{policy}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -764,8 +793,24 @@ sys.exit(0 if allowed else 13)
|
||||
);
|
||||
|
||||
assert!(
|
||||
policy.contains("(allow network* (subpath (param \"UNIX_SOCKET_PATH_0\")))"),
|
||||
"policy should allow explicitly configured unix sockets:\n{policy}"
|
||||
policy.contains("(allow system-socket (socket-domain AF_UNIX))"),
|
||||
"policy should allow AF_UNIX socket creation for configured unix sockets:\n{policy}"
|
||||
);
|
||||
assert!(
|
||||
policy.contains(
|
||||
"(allow network-bind (local unix-socket (subpath (param \"UNIX_SOCKET_PATH_0\"))))"
|
||||
),
|
||||
"policy should allow binding explicitly configured unix sockets:\n{policy}"
|
||||
);
|
||||
assert!(
|
||||
policy.contains(
|
||||
"(allow network-outbound (remote unix-socket (subpath (param \"UNIX_SOCKET_PATH_0\"))))"
|
||||
),
|
||||
"policy should allow connecting to explicitly configured unix sockets:\n{policy}"
|
||||
);
|
||||
assert!(
|
||||
!policy.contains("(allow network* (subpath"),
|
||||
"policy should no longer use the generic subpath unix-socket rules:\n{policy}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -839,8 +884,20 @@ sys.exit(0 if allowed else 13)
|
||||
);
|
||||
|
||||
assert!(
|
||||
policy.contains("(allow network* (subpath \"/\"))"),
|
||||
"policy should allow all unix sockets when flag is enabled:\n{policy}"
|
||||
policy.contains("(allow system-socket (socket-domain AF_UNIX))"),
|
||||
"policy should allow AF_UNIX socket creation when unix sockets are enabled:\n{policy}"
|
||||
);
|
||||
assert!(
|
||||
policy.contains("(allow network-bind (local unix-socket))"),
|
||||
"policy should allow binding unix sockets when enabled:\n{policy}"
|
||||
);
|
||||
assert!(
|
||||
policy.contains("(allow network-outbound (remote unix-socket))"),
|
||||
"policy should allow connecting to unix sockets when enabled:\n{policy}"
|
||||
);
|
||||
assert!(
|
||||
!policy.contains("(allow network* (subpath"),
|
||||
"policy should no longer use the generic subpath unix-socket rules:\n{policy}"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -866,6 +866,9 @@ mod tests {
|
||||
use codex_config::CONFIG_TOML_FILE;
|
||||
use codex_protocol::config_types::TrustLevel;
|
||||
use codex_protocol::models::FileSystemPermissions;
|
||||
use codex_protocol::models::MacOsAutomationPermission;
|
||||
use codex_protocol::models::MacOsPreferencesPermission;
|
||||
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
@@ -1454,6 +1457,37 @@ permissions: {}
|
||||
assert_eq!(outcome.skills[0].permission_profile, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_metadata_parses_macos_permissions_yaml() {
|
||||
let parsed = serde_yaml::from_str::<SkillMetadataFile>(
|
||||
r#"
|
||||
permissions:
|
||||
macos:
|
||||
macos_preferences: "read_write"
|
||||
macos_automation:
|
||||
- "com.apple.Notes"
|
||||
macos_accessibility: true
|
||||
macos_calendar: true
|
||||
"#,
|
||||
)
|
||||
.expect("parse skill metadata");
|
||||
|
||||
assert_eq!(
|
||||
parsed.permissions,
|
||||
Some(PermissionProfile {
|
||||
macos: Some(MacOsSeatbeltProfileExtensions {
|
||||
macos_preferences: MacOsPreferencesPermission::ReadWrite,
|
||||
macos_automation: MacOsAutomationPermission::BundleIds(vec![
|
||||
"com.apple.Notes".to_string(),
|
||||
]),
|
||||
macos_accessibility: true,
|
||||
macos_calendar: true,
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tokio::test]
|
||||
async fn loads_skill_macos_permissions_from_yaml() {
|
||||
@@ -1466,11 +1500,11 @@ permissions: {}
|
||||
r#"
|
||||
permissions:
|
||||
macos:
|
||||
preferences: "readwrite"
|
||||
automations:
|
||||
macos_preferences: "read_write"
|
||||
macos_automation:
|
||||
- "com.apple.Notes"
|
||||
accessibility: true
|
||||
calendar: true
|
||||
macos_accessibility: true
|
||||
macos_calendar: true
|
||||
"#,
|
||||
);
|
||||
|
||||
@@ -1486,15 +1520,13 @@ permissions:
|
||||
assert_eq!(
|
||||
outcome.skills[0].permission_profile,
|
||||
Some(PermissionProfile {
|
||||
macos: Some(codex_protocol::models::MacOsPermissions {
|
||||
preferences: Some(codex_protocol::models::MacOsPreferencesValue::Mode(
|
||||
"readwrite".to_string(),
|
||||
),),
|
||||
automations: Some(codex_protocol::models::MacOsAutomationValue::BundleIds(
|
||||
vec!["com.apple.Notes".to_string()],
|
||||
)),
|
||||
accessibility: Some(true),
|
||||
calendar: Some(true),
|
||||
macos: Some(MacOsSeatbeltProfileExtensions {
|
||||
macos_preferences: MacOsPreferencesPermission::ReadWrite,
|
||||
macos_automation: MacOsAutomationPermission::BundleIds(vec![
|
||||
"com.apple.Notes".to_string()
|
||||
],),
|
||||
macos_accessibility: true,
|
||||
macos_calendar: true,
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
@@ -1513,11 +1545,11 @@ permissions:
|
||||
r#"
|
||||
permissions:
|
||||
macos:
|
||||
preferences: "readwrite"
|
||||
automations:
|
||||
macos_preferences: "read_write"
|
||||
macos_automation:
|
||||
- "com.apple.Notes"
|
||||
accessibility: true
|
||||
calendar: true
|
||||
macos_accessibility: true
|
||||
macos_calendar: true
|
||||
"#,
|
||||
);
|
||||
|
||||
@@ -1533,15 +1565,13 @@ permissions:
|
||||
assert_eq!(
|
||||
outcome.skills[0].permission_profile,
|
||||
Some(PermissionProfile {
|
||||
macos: Some(codex_protocol::models::MacOsPermissions {
|
||||
preferences: Some(codex_protocol::models::MacOsPreferencesValue::Mode(
|
||||
"readwrite".to_string(),
|
||||
)),
|
||||
automations: Some(codex_protocol::models::MacOsAutomationValue::BundleIds(
|
||||
vec!["com.apple.Notes".to_string()],
|
||||
)),
|
||||
accessibility: Some(true),
|
||||
calendar: Some(true),
|
||||
macos: Some(MacOsSeatbeltProfileExtensions {
|
||||
macos_preferences: MacOsPreferencesPermission::ReadWrite,
|
||||
macos_automation: MacOsAutomationPermission::BundleIds(vec![
|
||||
"com.apple.Notes".to_string()
|
||||
],),
|
||||
macos_accessibility: true,
|
||||
macos_calendar: true,
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
|
||||
@@ -4,7 +4,6 @@ pub(crate) mod invocation_utils;
|
||||
pub mod loader;
|
||||
pub mod manager;
|
||||
pub mod model;
|
||||
pub mod permissions;
|
||||
pub mod remote;
|
||||
pub mod render;
|
||||
pub mod system;
|
||||
|
||||
@@ -1,454 +0,0 @@
|
||||
#[cfg(any(unix, test))]
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use codex_protocol::models::MacOsAutomationValue;
|
||||
#[cfg(any(unix, test))]
|
||||
use codex_protocol::models::MacOsPermissions;
|
||||
#[cfg(target_os = "macos")]
|
||||
use codex_protocol::models::MacOsPreferencesValue;
|
||||
#[cfg(any(unix, test))]
|
||||
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
|
||||
#[cfg(any(unix, test))]
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
#[cfg(any(unix, test))]
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
#[cfg(any(unix, test))]
|
||||
use dunce::canonicalize as canonicalize_path;
|
||||
#[cfg(any(unix, test))]
|
||||
use tracing::warn;
|
||||
|
||||
#[cfg(any(unix, test))]
|
||||
use crate::config::Constrained;
|
||||
#[cfg(any(unix, test))]
|
||||
use crate::config::Permissions;
|
||||
#[cfg(any(unix, test))]
|
||||
use crate::config::types::ShellEnvironmentPolicy;
|
||||
#[cfg(any(unix, test))]
|
||||
use crate::protocol::AskForApproval;
|
||||
#[cfg(any(unix, test))]
|
||||
use crate::protocol::ReadOnlyAccess;
|
||||
#[cfg(any(unix, test))]
|
||||
use crate::protocol::SandboxPolicy;
|
||||
|
||||
/// Compiles a skill `PermissionProfile` for the Unix shell escalation path.
|
||||
///
|
||||
/// Normal Windows builds do not currently call this helper, so it is only
|
||||
/// compiled on Unix and in tests.
|
||||
#[cfg(any(unix, test))]
|
||||
pub(crate) fn compile_permission_profile(
|
||||
permissions: Option<PermissionProfile>,
|
||||
) -> Option<Permissions> {
|
||||
let PermissionProfile {
|
||||
network,
|
||||
file_system,
|
||||
macos,
|
||||
} = permissions?;
|
||||
let network_access = network.and_then(|value| value.enabled).unwrap_or_default();
|
||||
let file_system = file_system.unwrap_or_default();
|
||||
let fs_read = normalize_permission_paths(
|
||||
file_system.read.as_deref().unwrap_or_default(),
|
||||
"permissions.file_system.read",
|
||||
);
|
||||
let fs_write = normalize_permission_paths(
|
||||
file_system.write.as_deref().unwrap_or_default(),
|
||||
"permissions.file_system.write",
|
||||
);
|
||||
let sandbox_policy = if !fs_write.is_empty() {
|
||||
SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: fs_write,
|
||||
read_only_access: if fs_read.is_empty() {
|
||||
ReadOnlyAccess::FullAccess
|
||||
} else {
|
||||
ReadOnlyAccess::Restricted {
|
||||
include_platform_defaults: true,
|
||||
readable_roots: fs_read,
|
||||
}
|
||||
},
|
||||
network_access,
|
||||
exclude_tmpdir_env_var: false,
|
||||
exclude_slash_tmp: false,
|
||||
}
|
||||
} else if !fs_read.is_empty() {
|
||||
SandboxPolicy::ReadOnly {
|
||||
access: ReadOnlyAccess::Restricted {
|
||||
include_platform_defaults: true,
|
||||
readable_roots: fs_read,
|
||||
},
|
||||
network_access,
|
||||
}
|
||||
} else if network_access {
|
||||
SandboxPolicy::ReadOnly {
|
||||
access: ReadOnlyAccess::FullAccess,
|
||||
network_access: true,
|
||||
}
|
||||
} else {
|
||||
// Default sandbox policy
|
||||
SandboxPolicy::new_read_only_policy()
|
||||
};
|
||||
let macos_permissions = macos.unwrap_or_default();
|
||||
let macos_seatbelt_profile_extensions =
|
||||
build_macos_seatbelt_profile_extensions(&macos_permissions);
|
||||
|
||||
Some(Permissions {
|
||||
approval_policy: Constrained::allow_any(AskForApproval::Never),
|
||||
sandbox_policy: Constrained::allow_any(sandbox_policy),
|
||||
network: None,
|
||||
allow_login_shell: true,
|
||||
shell_environment_policy: ShellEnvironmentPolicy::default(),
|
||||
windows_sandbox_mode: None,
|
||||
macos_seatbelt_profile_extensions,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(any(unix, test))]
|
||||
fn normalize_permission_paths(values: &[AbsolutePathBuf], field: &str) -> Vec<AbsolutePathBuf> {
|
||||
let mut paths = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
|
||||
for value in values {
|
||||
let Some(path) = normalize_permission_path(value, field) else {
|
||||
continue;
|
||||
};
|
||||
if seen.insert(path.clone()) {
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
paths
|
||||
}
|
||||
|
||||
#[cfg(any(unix, test))]
|
||||
fn normalize_permission_path(value: &AbsolutePathBuf, field: &str) -> Option<AbsolutePathBuf> {
|
||||
let canonicalized = canonicalize_path(value.as_path()).unwrap_or_else(|_| value.to_path_buf());
|
||||
match AbsolutePathBuf::from_absolute_path(&canonicalized) {
|
||||
Ok(path) => Some(path),
|
||||
Err(error) => {
|
||||
warn!("ignoring {field}: expected absolute path, got {canonicalized:?}: {error}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn build_macos_seatbelt_profile_extensions(
|
||||
permissions: &MacOsPermissions,
|
||||
) -> Option<MacOsSeatbeltProfileExtensions> {
|
||||
let defaults = MacOsSeatbeltProfileExtensions::default();
|
||||
|
||||
let extensions = MacOsSeatbeltProfileExtensions {
|
||||
macos_preferences: resolve_macos_preferences_permission(
|
||||
permissions.preferences.as_ref(),
|
||||
defaults.macos_preferences,
|
||||
),
|
||||
macos_automation: resolve_macos_automation_permission(
|
||||
permissions.automations.as_ref(),
|
||||
defaults.macos_automation,
|
||||
),
|
||||
macos_accessibility: permissions
|
||||
.accessibility
|
||||
.unwrap_or(defaults.macos_accessibility),
|
||||
macos_calendar: permissions.calendar.unwrap_or(defaults.macos_calendar),
|
||||
};
|
||||
Some(extensions)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn resolve_macos_preferences_permission(
|
||||
value: Option<&MacOsPreferencesValue>,
|
||||
default: crate::seatbelt_permissions::MacOsPreferencesPermission,
|
||||
) -> crate::seatbelt_permissions::MacOsPreferencesPermission {
|
||||
use crate::seatbelt_permissions::MacOsPreferencesPermission;
|
||||
|
||||
match value {
|
||||
Some(MacOsPreferencesValue::Bool(true)) => MacOsPreferencesPermission::ReadOnly,
|
||||
Some(MacOsPreferencesValue::Bool(false)) => MacOsPreferencesPermission::None,
|
||||
Some(MacOsPreferencesValue::Mode(mode)) => {
|
||||
let mode = mode.trim();
|
||||
if mode.eq_ignore_ascii_case("readonly") || mode.eq_ignore_ascii_case("read-only") {
|
||||
MacOsPreferencesPermission::ReadOnly
|
||||
} else if mode.eq_ignore_ascii_case("readwrite")
|
||||
|| mode.eq_ignore_ascii_case("read-write")
|
||||
{
|
||||
MacOsPreferencesPermission::ReadWrite
|
||||
} else {
|
||||
warn!(
|
||||
"ignoring permissions.macos.preferences: expected true/false, readonly, or readwrite"
|
||||
);
|
||||
default
|
||||
}
|
||||
}
|
||||
None => default,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn resolve_macos_automation_permission(
|
||||
value: Option<&MacOsAutomationValue>,
|
||||
default: crate::seatbelt_permissions::MacOsAutomationPermission,
|
||||
) -> crate::seatbelt_permissions::MacOsAutomationPermission {
|
||||
use crate::seatbelt_permissions::MacOsAutomationPermission;
|
||||
|
||||
match value {
|
||||
Some(MacOsAutomationValue::Bool(true)) => MacOsAutomationPermission::All,
|
||||
Some(MacOsAutomationValue::Bool(false)) => MacOsAutomationPermission::None,
|
||||
Some(MacOsAutomationValue::BundleIds(bundle_ids)) => {
|
||||
let bundle_ids = bundle_ids
|
||||
.iter()
|
||||
.map(|bundle_id| bundle_id.trim())
|
||||
.filter(|bundle_id| !bundle_id.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<String>>();
|
||||
if bundle_ids.is_empty() {
|
||||
MacOsAutomationPermission::None
|
||||
} else {
|
||||
MacOsAutomationPermission::BundleIds(bundle_ids)
|
||||
}
|
||||
}
|
||||
None => default,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(not(target_os = "macos"), any(unix, test)))]
|
||||
fn build_macos_seatbelt_profile_extensions(
|
||||
_: &MacOsPermissions,
|
||||
) -> Option<MacOsSeatbeltProfileExtensions> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::compile_permission_profile;
|
||||
use crate::config::Constrained;
|
||||
use crate::config::Permissions;
|
||||
use crate::config::types::ShellEnvironmentPolicy;
|
||||
use crate::protocol::AskForApproval;
|
||||
use crate::protocol::ReadOnlyAccess;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use codex_protocol::models::FileSystemPermissions;
|
||||
#[cfg(target_os = "macos")]
|
||||
use codex_protocol::models::MacOsAutomationValue;
|
||||
#[cfg(target_os = "macos")]
|
||||
use codex_protocol::models::MacOsPermissions;
|
||||
#[cfg(target_os = "macos")]
|
||||
use codex_protocol::models::MacOsPreferencesValue;
|
||||
use codex_protocol::models::NetworkPermissions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
fn absolute_path(path: &Path) -> AbsolutePathBuf {
|
||||
AbsolutePathBuf::try_from(path).expect("absolute path")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compile_permission_profile_normalizes_paths() {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let skill_dir = tempdir.path().join("skill");
|
||||
fs::create_dir_all(skill_dir.join("scripts")).expect("skill dir");
|
||||
let read_dir = skill_dir.join("data");
|
||||
fs::create_dir_all(&read_dir).expect("read dir");
|
||||
|
||||
let profile = compile_permission_profile(Some(PermissionProfile {
|
||||
network: Some(NetworkPermissions {
|
||||
enabled: Some(true),
|
||||
}),
|
||||
file_system: Some(FileSystemPermissions {
|
||||
read: Some(vec![
|
||||
absolute_path(&skill_dir.join("data")),
|
||||
absolute_path(&skill_dir.join("data")),
|
||||
absolute_path(&skill_dir.join("scripts/../data")),
|
||||
]),
|
||||
write: Some(vec![absolute_path(&skill_dir.join("output"))]),
|
||||
}),
|
||||
..Default::default()
|
||||
}))
|
||||
.expect("profile");
|
||||
|
||||
assert_eq!(
|
||||
profile,
|
||||
Permissions {
|
||||
approval_policy: Constrained::allow_any(AskForApproval::Never),
|
||||
sandbox_policy: Constrained::allow_any(SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![
|
||||
AbsolutePathBuf::try_from(skill_dir.join("output"))
|
||||
.expect("absolute output path")
|
||||
],
|
||||
read_only_access: ReadOnlyAccess::Restricted {
|
||||
include_platform_defaults: true,
|
||||
readable_roots: vec![
|
||||
AbsolutePathBuf::try_from(
|
||||
dunce::canonicalize(&read_dir).unwrap_or(read_dir)
|
||||
)
|
||||
.expect("absolute read path")
|
||||
],
|
||||
},
|
||||
network_access: true,
|
||||
exclude_tmpdir_env_var: false,
|
||||
exclude_slash_tmp: false,
|
||||
}),
|
||||
network: None,
|
||||
allow_login_shell: true,
|
||||
shell_environment_policy: ShellEnvironmentPolicy::default(),
|
||||
windows_sandbox_mode: None,
|
||||
#[cfg(target_os = "macos")]
|
||||
macos_seatbelt_profile_extensions: Some(
|
||||
crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions::default(),
|
||||
),
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
macos_seatbelt_profile_extensions: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compile_permission_profile_without_permissions_has_empty_profile() {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let skill_dir = tempdir.path().join("skill");
|
||||
fs::create_dir_all(&skill_dir).expect("skill dir");
|
||||
|
||||
let profile = compile_permission_profile(None);
|
||||
|
||||
assert_eq!(profile, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compile_permission_profile_with_network_only_uses_read_only_policy() {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let skill_dir = tempdir.path().join("skill");
|
||||
fs::create_dir_all(&skill_dir).expect("skill dir");
|
||||
|
||||
let profile = compile_permission_profile(Some(PermissionProfile {
|
||||
network: Some(NetworkPermissions {
|
||||
enabled: Some(true),
|
||||
}),
|
||||
..Default::default()
|
||||
}))
|
||||
.expect("profile");
|
||||
|
||||
assert_eq!(
|
||||
profile,
|
||||
Permissions {
|
||||
approval_policy: Constrained::allow_any(AskForApproval::Never),
|
||||
sandbox_policy: Constrained::allow_any(SandboxPolicy::ReadOnly {
|
||||
access: ReadOnlyAccess::FullAccess,
|
||||
network_access: true,
|
||||
}),
|
||||
network: None,
|
||||
allow_login_shell: true,
|
||||
shell_environment_policy: ShellEnvironmentPolicy::default(),
|
||||
windows_sandbox_mode: None,
|
||||
#[cfg(target_os = "macos")]
|
||||
macos_seatbelt_profile_extensions: Some(
|
||||
crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions::default(),
|
||||
),
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
macos_seatbelt_profile_extensions: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compile_permission_profile_with_network_and_read_only_paths_uses_read_only_policy() {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let skill_dir = tempdir.path().join("skill");
|
||||
let read_dir = skill_dir.join("data");
|
||||
fs::create_dir_all(&read_dir).expect("read dir");
|
||||
|
||||
let profile = compile_permission_profile(Some(PermissionProfile {
|
||||
network: Some(NetworkPermissions {
|
||||
enabled: Some(true),
|
||||
}),
|
||||
file_system: Some(FileSystemPermissions {
|
||||
read: Some(vec![absolute_path(&skill_dir.join("data"))]),
|
||||
write: Some(Vec::new()),
|
||||
}),
|
||||
..Default::default()
|
||||
}))
|
||||
.expect("profile");
|
||||
|
||||
assert_eq!(
|
||||
profile,
|
||||
Permissions {
|
||||
approval_policy: Constrained::allow_any(AskForApproval::Never),
|
||||
sandbox_policy: Constrained::allow_any(SandboxPolicy::ReadOnly {
|
||||
access: ReadOnlyAccess::Restricted {
|
||||
include_platform_defaults: true,
|
||||
readable_roots: vec![
|
||||
AbsolutePathBuf::try_from(
|
||||
dunce::canonicalize(&read_dir).unwrap_or(read_dir)
|
||||
)
|
||||
.expect("absolute read path")
|
||||
],
|
||||
},
|
||||
network_access: true,
|
||||
}),
|
||||
network: None,
|
||||
allow_login_shell: true,
|
||||
shell_environment_policy: ShellEnvironmentPolicy::default(),
|
||||
windows_sandbox_mode: None,
|
||||
#[cfg(target_os = "macos")]
|
||||
macos_seatbelt_profile_extensions: Some(
|
||||
crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions::default(),
|
||||
),
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
macos_seatbelt_profile_extensions: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn compile_permission_profile_builds_macos_permission_file() {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let skill_dir = tempdir.path().join("skill");
|
||||
fs::create_dir_all(&skill_dir).expect("skill dir");
|
||||
|
||||
let profile = compile_permission_profile(Some(PermissionProfile {
|
||||
macos: Some(MacOsPermissions {
|
||||
preferences: Some(MacOsPreferencesValue::Mode("readwrite".to_string())),
|
||||
automations: Some(MacOsAutomationValue::BundleIds(vec![
|
||||
"com.apple.Notes".to_string(),
|
||||
])),
|
||||
accessibility: Some(true),
|
||||
calendar: Some(true),
|
||||
}),
|
||||
..Default::default()
|
||||
}))
|
||||
.expect("profile");
|
||||
|
||||
assert_eq!(
|
||||
profile.macos_seatbelt_profile_extensions,
|
||||
Some(
|
||||
crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions {
|
||||
macos_preferences:
|
||||
crate::seatbelt_permissions::MacOsPreferencesPermission::ReadWrite,
|
||||
macos_automation:
|
||||
crate::seatbelt_permissions::MacOsAutomationPermission::BundleIds(vec![
|
||||
"com.apple.Notes".to_string()
|
||||
],),
|
||||
macos_accessibility: true,
|
||||
macos_calendar: true,
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn compile_permission_profile_uses_macos_defaults_when_values_missing() {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let skill_dir = tempdir.path().join("skill");
|
||||
fs::create_dir_all(&skill_dir).expect("skill dir");
|
||||
|
||||
let profile =
|
||||
compile_permission_profile(Some(PermissionProfile::default())).expect("profile");
|
||||
|
||||
assert_eq!(
|
||||
profile.macos_seatbelt_profile_extensions,
|
||||
Some(crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions::default())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -89,7 +89,7 @@ fn resolve_workdir_base_path(
|
||||
}
|
||||
|
||||
/// Validates feature/policy constraints for `with_additional_permissions` and
|
||||
/// returns normalized absolute paths. Errors if paths are invalid.
|
||||
/// normalizes any path-based permissions. Errors if the request is invalid.
|
||||
pub(super) fn normalize_and_validate_additional_permissions(
|
||||
request_permission_enabled: bool,
|
||||
approval_policy: AskForApproval,
|
||||
@@ -119,14 +119,18 @@ pub(super) fn normalize_and_validate_additional_permissions(
|
||||
}
|
||||
let Some(additional_permissions) = additional_permissions else {
|
||||
return Err(
|
||||
"missing `additional_permissions`; provide `file_system.read` and/or `file_system.write` when using `with_additional_permissions`"
|
||||
"missing `additional_permissions`; provide at least one of `network`, `file_system`, or `macos` when using `with_additional_permissions`"
|
||||
.to_string(),
|
||||
);
|
||||
};
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
if additional_permissions.macos.is_some() {
|
||||
return Err("`additional_permissions.macos` is only supported on macOS".to_string());
|
||||
}
|
||||
let normalized = normalize_additional_permissions(additional_permissions)?;
|
||||
if normalized.is_empty() {
|
||||
return Err(
|
||||
"`additional_permissions` must include at least one path in `file_system.read` or `file_system.write`"
|
||||
"`additional_permissions` must include at least one requested permission in `network`, `file_system`, or `macos`"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -268,6 +268,7 @@ mod tests {
|
||||
install_url: None,
|
||||
is_accessible: true,
|
||||
is_enabled: enabled,
|
||||
plugin_display_names: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,6 +296,7 @@ mod tests {
|
||||
},
|
||||
connector_id: connector_id.map(str::to_string),
|
||||
connector_name: connector_id.map(str::to_string),
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ context.btoa = (data) => Buffer.from(data, "binary").toString("base64");
|
||||
|
||||
// REPL state model:
|
||||
// - Every exec is compiled as a fresh ESM "cell".
|
||||
// - `previousModule` is the most recently evaluated module namespace.
|
||||
// - `previousModule` is the most recently committed module namespace.
|
||||
// - `previousBindings` tracks which top-level names should be carried forward.
|
||||
// Each new cell imports a synthetic view of the previous namespace and
|
||||
// redeclares those names so user variables behave like a persistent REPL.
|
||||
@@ -86,6 +86,12 @@ let previousModule = null;
|
||||
/** @type {Binding[]} */
|
||||
let previousBindings = [];
|
||||
let cellCounter = 0;
|
||||
let internalBindingCounter = 0;
|
||||
const internalBindingSalt = (() => {
|
||||
const raw = process.env.CODEX_THREAD_ID ?? "";
|
||||
const sanitized = raw.replace(/[^A-Za-z0-9_$]/g, "_");
|
||||
return sanitized || "session";
|
||||
})();
|
||||
let activeExecId = null;
|
||||
let fatalExitScheduled = false;
|
||||
|
||||
@@ -553,17 +559,447 @@ function collectBindings(ast) {
|
||||
return Array.from(map.entries()).map(([name, kind]) => ({ name, kind }));
|
||||
}
|
||||
|
||||
function collectPatternBindingNames(pattern) {
|
||||
const map = new Map();
|
||||
collectPatternNames(pattern, "binding", map);
|
||||
return Array.from(map.keys());
|
||||
}
|
||||
|
||||
function nextInternalBindingName() {
|
||||
// We intentionally do not scan user-declared names here. Internal helpers use
|
||||
// a per-thread salt plus a counter instead. A user could still collide by
|
||||
// deliberately spelling the exact generated name, but the thread-id salt
|
||||
// keeps accidental collisions negligible while avoiding more AST bookkeeping.
|
||||
return `__codex_internal_commit_${internalBindingSalt}_${internalBindingCounter++}`;
|
||||
}
|
||||
|
||||
function buildMarkCommittedExpression(names, markCommittedFnName) {
|
||||
const serializedNames = names.map((name) => JSON.stringify(name)).join(", ");
|
||||
return `(${markCommittedFnName}(${serializedNames}), undefined)`;
|
||||
}
|
||||
|
||||
function tryReadBindingValue(module, bindingName) {
|
||||
if (!module) {
|
||||
return { ok: false, value: undefined };
|
||||
}
|
||||
|
||||
try {
|
||||
return { ok: true, value: module.namespace[bindingName] };
|
||||
} catch {
|
||||
return { ok: false, value: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
function instrumentVariableDeclarationSource(
|
||||
code,
|
||||
declaration,
|
||||
markCommittedFnName,
|
||||
) {
|
||||
if (!declaration.declarations?.length) {
|
||||
return code.slice(declaration.start, declaration.end);
|
||||
}
|
||||
|
||||
const prefix = code.slice(declaration.start, declaration.declarations[0].start);
|
||||
const suffix = code.slice(
|
||||
declaration.declarations[declaration.declarations.length - 1].end,
|
||||
declaration.end,
|
||||
);
|
||||
const parts = [];
|
||||
|
||||
for (const decl of declaration.declarations) {
|
||||
parts.push(code.slice(decl.start, decl.end));
|
||||
|
||||
const names = collectPatternBindingNames(decl.id);
|
||||
if (names.length > 0) {
|
||||
const helperName = nextInternalBindingName();
|
||||
parts.push(
|
||||
`${helperName} = ${buildMarkCommittedExpression(names, markCommittedFnName)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return `${prefix}${parts.join(", ")}${suffix}`;
|
||||
}
|
||||
|
||||
function instrumentLoopBody(code, body, names, guardName, markCommittedFnName) {
|
||||
const marker = `if (${guardName}) { ${guardName} = false; ${markCommittedFnName}(${names
|
||||
.map((name) => JSON.stringify(name))
|
||||
.join(", ")}); }`;
|
||||
const bodyCode = code.slice(body.start, body.end);
|
||||
|
||||
if (body.type === "BlockStatement") {
|
||||
return `{ ${marker}${bodyCode.slice(1)}`;
|
||||
}
|
||||
|
||||
return `{ ${marker} ${bodyCode} }`;
|
||||
}
|
||||
|
||||
function applyReplacements(code, replacements) {
|
||||
let instrumentedCode = code;
|
||||
|
||||
for (const replacement of replacements.sort((a, b) => b.start - a.start)) {
|
||||
instrumentedCode =
|
||||
instrumentedCode.slice(0, replacement.start) +
|
||||
replacement.text +
|
||||
instrumentedCode.slice(replacement.end);
|
||||
}
|
||||
|
||||
return instrumentedCode;
|
||||
}
|
||||
|
||||
function collectHoistedVarDeclarationStarts(ast) {
|
||||
const varDeclarationStarts = new Map();
|
||||
|
||||
const recordDeclarationStart = (map, name, start) => {
|
||||
const existingStart = map.get(name);
|
||||
if (existingStart === undefined || start < existingStart) {
|
||||
map.set(name, start);
|
||||
}
|
||||
};
|
||||
|
||||
const recordVarDeclarationStarts = (declaration) => {
|
||||
for (const name of collectPatternBindingNames(declaration.id)) {
|
||||
recordDeclarationStart(varDeclarationStarts, name, declaration.start);
|
||||
}
|
||||
};
|
||||
|
||||
for (const stmt of ast.body ?? []) {
|
||||
if (stmt.type === "VariableDeclaration" && stmt.kind === "var") {
|
||||
for (const declaration of stmt.declarations ?? []) {
|
||||
recordVarDeclarationStarts(declaration);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
stmt.type === "ForStatement" &&
|
||||
stmt.init?.type === "VariableDeclaration" &&
|
||||
stmt.init.kind === "var"
|
||||
) {
|
||||
for (const declaration of stmt.init.declarations ?? []) {
|
||||
recordVarDeclarationStarts(declaration);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
(stmt.type === "ForInStatement" || stmt.type === "ForOfStatement") &&
|
||||
stmt.left?.type === "VariableDeclaration" &&
|
||||
stmt.left.kind === "var"
|
||||
) {
|
||||
for (const declaration of stmt.left.declarations ?? []) {
|
||||
recordVarDeclarationStarts(declaration);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return varDeclarationStarts;
|
||||
}
|
||||
|
||||
function collectFutureVarWriteReplacements(
|
||||
code,
|
||||
ast,
|
||||
{
|
||||
helperDeclarations = null,
|
||||
markCommittedFnName = null,
|
||||
} = {},
|
||||
) {
|
||||
// Failed-cell hoisted tracking intentionally stays small here. We only mark
|
||||
// direct top-level writes to future `var` bindings, plus top-level
|
||||
// declaration-site markers handled later in `instrumentCurrentBindings`.
|
||||
// We do not recurse through nested statement structure because that quickly
|
||||
// requires real lexical-scope tracking for blocks, loop scopes, catch
|
||||
// bindings, and similar shadowing cases. Supported write recovery is limited
|
||||
// to direct top-level expression statements such as `x = 1`, `x += 1`,
|
||||
// `x++`, and logical assignments.
|
||||
const varDeclarationStarts = collectHoistedVarDeclarationStarts(ast);
|
||||
if (varDeclarationStarts.size === 0) {
|
||||
return [];
|
||||
}
|
||||
const replacements = [];
|
||||
const replacementKeys = new Set();
|
||||
|
||||
if (!markCommittedFnName) {
|
||||
throw new Error(
|
||||
"collectFutureVarWriteReplacements expected a commit marker binding name",
|
||||
);
|
||||
}
|
||||
|
||||
const addReplacement = (start, end, text) => {
|
||||
const key = `${start}:${end}`;
|
||||
if (!replacementKeys.has(key)) {
|
||||
replacementKeys.add(key);
|
||||
replacements.push({ start, end, text });
|
||||
}
|
||||
};
|
||||
|
||||
const getFutureVarName = (identifier) => {
|
||||
if (!identifier || identifier.type !== "Identifier") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const declarationStart = varDeclarationStarts.get(identifier.name);
|
||||
if (
|
||||
declarationStart === undefined ||
|
||||
identifier.start >= declarationStart
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return identifier.name;
|
||||
};
|
||||
|
||||
const instrumentUpdateExpression = (node, identifier) => {
|
||||
const bindingName = getFutureVarName(identifier);
|
||||
if (!bindingName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
addReplacement(
|
||||
node.start,
|
||||
node.end,
|
||||
`(${markCommittedFnName}(${JSON.stringify(bindingName)}), ${code.slice(
|
||||
node.start,
|
||||
node.end,
|
||||
)})`,
|
||||
);
|
||||
return true;
|
||||
};
|
||||
|
||||
const instrumentAssignmentExpression = (node) => {
|
||||
if (node.left.type !== "Identifier") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const bindingName = getFutureVarName(node.left);
|
||||
if (!bindingName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
node.operator === "&&=" ||
|
||||
node.operator === "||=" ||
|
||||
node.operator === "??="
|
||||
) {
|
||||
if (!helperDeclarations) {
|
||||
throw new Error(
|
||||
"collectFutureVarWriteReplacements expected helperDeclarations for logical assignment rewriting",
|
||||
);
|
||||
}
|
||||
|
||||
const helperName = nextInternalBindingName();
|
||||
helperDeclarations.push(`let ${helperName};`);
|
||||
const shortCircuitOperator =
|
||||
node.operator === "&&="
|
||||
? "&&"
|
||||
: node.operator === "||="
|
||||
? "||"
|
||||
: "??";
|
||||
addReplacement(
|
||||
node.start,
|
||||
node.end,
|
||||
`((${helperName} = ${node.left.name}), ${helperName} ${shortCircuitOperator} ((${node.left.name} = ${code.slice(node.right.start, node.right.end)}), ${buildMarkCommittedExpression([bindingName], markCommittedFnName)}, ${node.left.name}))`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
addReplacement(
|
||||
node.start,
|
||||
node.end,
|
||||
`((${code.slice(node.start, node.end)}), ${buildMarkCommittedExpression([bindingName], markCommittedFnName)}, ${node.left.name})`,
|
||||
);
|
||||
return true;
|
||||
};
|
||||
|
||||
const unwrapParenthesizedExpression = (node) => {
|
||||
let current = node;
|
||||
while (current?.type === "ParenthesizedExpression") {
|
||||
current = current.expression;
|
||||
}
|
||||
return current;
|
||||
};
|
||||
|
||||
for (const statement of ast.body ?? []) {
|
||||
if (statement.type !== "ExpressionStatement") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const expression = unwrapParenthesizedExpression(statement.expression);
|
||||
if (!expression) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
expression.type === "UpdateExpression" &&
|
||||
expression.argument.type === "Identifier"
|
||||
) {
|
||||
instrumentUpdateExpression(expression, expression.argument);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (expression.type === "AssignmentExpression") {
|
||||
instrumentAssignmentExpression(expression);
|
||||
}
|
||||
}
|
||||
|
||||
return replacements;
|
||||
}
|
||||
|
||||
function instrumentCurrentBindings(
|
||||
code,
|
||||
ast,
|
||||
currentBindings,
|
||||
priorBindings,
|
||||
markCommittedFnName,
|
||||
) {
|
||||
if (currentBindings.length === 0) {
|
||||
return code;
|
||||
}
|
||||
|
||||
const replacements = [];
|
||||
|
||||
for (const stmt of ast.body ?? []) {
|
||||
if (stmt.type === "VariableDeclaration") {
|
||||
replacements.push({
|
||||
start: stmt.start,
|
||||
end: stmt.end,
|
||||
text: instrumentVariableDeclarationSource(
|
||||
code,
|
||||
stmt,
|
||||
markCommittedFnName,
|
||||
),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (stmt.type === "FunctionDeclaration" && stmt.id) {
|
||||
replacements.push({
|
||||
start: stmt.start,
|
||||
end: stmt.end,
|
||||
// Keep function source text stable for things like `foo.toString()`.
|
||||
// Pre-declaration uses are tracked separately by instrumenting the
|
||||
// top-level expressions that actually read the hoisted function value.
|
||||
text: `${code.slice(stmt.start, stmt.end)}\n;${markCommittedFnName}(${JSON.stringify(stmt.id.name)});`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (stmt.type === "ClassDeclaration" && stmt.id) {
|
||||
replacements.push({
|
||||
start: stmt.start,
|
||||
end: stmt.end,
|
||||
text: `${code.slice(stmt.start, stmt.end)}\n;${markCommittedFnName}(${JSON.stringify(stmt.id.name)});`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
stmt.type === "ForStatement" &&
|
||||
stmt.init &&
|
||||
stmt.init.type === "VariableDeclaration" &&
|
||||
stmt.init.kind === "var"
|
||||
) {
|
||||
replacements.push({
|
||||
start: stmt.start,
|
||||
end: stmt.end,
|
||||
text: `${code.slice(stmt.start, stmt.init.start)}${instrumentVariableDeclarationSource(
|
||||
code,
|
||||
stmt.init,
|
||||
markCommittedFnName,
|
||||
)}${code.slice(stmt.init.end, stmt.end)}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
(stmt.type === "ForInStatement" || stmt.type === "ForOfStatement") &&
|
||||
stmt.left &&
|
||||
stmt.left.type === "VariableDeclaration" &&
|
||||
stmt.left.kind === "var"
|
||||
) {
|
||||
const names = stmt.left.declarations.flatMap((decl) =>
|
||||
collectPatternBindingNames(decl.id),
|
||||
);
|
||||
if (names.length > 0) {
|
||||
const guardName = nextInternalBindingName();
|
||||
replacements.push({
|
||||
start: stmt.start,
|
||||
end: stmt.end,
|
||||
// Mark top-level `for...in` / `for...of` vars on the first body
|
||||
// execution instead of every iteration. This keeps hot loops cheap
|
||||
// after the first pass while still preserving vars for the common
|
||||
// case where the loop actually ran before a later throw.
|
||||
//
|
||||
// The tradeoff is that `for (var x of []) {}` in a failed cell will
|
||||
// not carry `x` forward as `undefined`, because the body never runs
|
||||
// and the one-time marker never fires. We accept that edge case:
|
||||
// `var` is redeclarable, and the only lost state is an unassigned
|
||||
// `undefined` from an empty top-level loop in a cell that later
|
||||
// fails.
|
||||
text: `let ${guardName} = true;\n${code.slice(
|
||||
stmt.start,
|
||||
stmt.body.start,
|
||||
)}${instrumentLoopBody(
|
||||
code,
|
||||
stmt.body,
|
||||
names,
|
||||
guardName,
|
||||
markCommittedFnName,
|
||||
)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return applyReplacements(code, replacements);
|
||||
}
|
||||
|
||||
async function buildModuleSource(code) {
|
||||
const meriyah = await meriyahPromise;
|
||||
const ast = meriyah.parseModule(code, {
|
||||
next: true,
|
||||
module: true,
|
||||
ranges: false,
|
||||
ranges: true,
|
||||
loc: false,
|
||||
disableWebCompat: true,
|
||||
});
|
||||
const currentBindings = collectBindings(ast);
|
||||
const priorBindings = previousModule ? previousBindings : [];
|
||||
const helperDeclarations = [];
|
||||
const markCommittedFnName = nextInternalBindingName();
|
||||
const markPreludeCompletedFnName = nextInternalBindingName();
|
||||
helperDeclarations.push(
|
||||
// `import.meta` is syntax-level and cannot be shadowed by user bindings
|
||||
// like `const globalThis = ...`, so alias the marker helper through it
|
||||
// once in the prelude and use that stable local binding everywhere.
|
||||
// Then delete the raw import.meta hooks so user code cannot spoof
|
||||
// committed bindings by calling them directly.
|
||||
`const ${markCommittedFnName} = import.meta.__codexInternalMarkCommittedBindings;`,
|
||||
`const ${markPreludeCompletedFnName} = import.meta.__codexInternalMarkPreludeCompleted;`,
|
||||
"delete import.meta.__codexInternalMarkCommittedBindings;",
|
||||
"delete import.meta.__codexInternalMarkPreludeCompleted;",
|
||||
);
|
||||
const writeInstrumentedCode = applyReplacements(
|
||||
code,
|
||||
collectFutureVarWriteReplacements(code, ast, {
|
||||
helperDeclarations,
|
||||
markCommittedFnName,
|
||||
}),
|
||||
);
|
||||
const instrumentedAst = meriyah.parseModule(writeInstrumentedCode, {
|
||||
next: true,
|
||||
module: true,
|
||||
ranges: true,
|
||||
loc: false,
|
||||
disableWebCompat: true,
|
||||
});
|
||||
const instrumentedCode = instrumentCurrentBindings(
|
||||
writeInstrumentedCode,
|
||||
instrumentedAst,
|
||||
currentBindings,
|
||||
priorBindings,
|
||||
markCommittedFnName,
|
||||
);
|
||||
|
||||
let prelude = "";
|
||||
if (previousModule && priorBindings.length) {
|
||||
@@ -578,6 +1014,10 @@ async function buildModuleSource(code) {
|
||||
.join("\n");
|
||||
prelude += "\n";
|
||||
}
|
||||
if (helperDeclarations.length > 0) {
|
||||
prelude += `${helperDeclarations.join("\n")}\n`;
|
||||
}
|
||||
prelude += `${markPreludeCompletedFnName}();\n`;
|
||||
|
||||
const mergedBindings = new Map();
|
||||
for (const binding of priorBindings) {
|
||||
@@ -596,7 +1036,60 @@ async function buildModuleSource(code) {
|
||||
name,
|
||||
kind,
|
||||
}));
|
||||
return { source: `${prelude}${code}${exportStmt}`, nextBindings };
|
||||
return {
|
||||
source: `${prelude}${instrumentedCode}${exportStmt}`,
|
||||
currentBindings,
|
||||
nextBindings,
|
||||
priorBindings,
|
||||
};
|
||||
}
|
||||
|
||||
function canReadCommittedBinding(module, binding) {
|
||||
if (
|
||||
!module ||
|
||||
binding.kind === "var" ||
|
||||
binding.kind === "function"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return tryReadBindingValue(module, binding.name).ok;
|
||||
}
|
||||
// Failed cells keep prior bindings plus the current-cell bindings whose
|
||||
// initialization definitely ran before the throw. That means:
|
||||
// - lexical bindings (`const` / `let` / `class`) can fall back to namespace
|
||||
// readability, which preserves names whose initialization already completed
|
||||
// even when a later step in the same declarator throws
|
||||
// - `var` / `function` bindings only persist when an explicit declaration-site
|
||||
// or write-site marker fired, so unreached hoisted bindings do not become
|
||||
// ghost bindings in later cells
|
||||
function collectCommittedBindings(
|
||||
module,
|
||||
priorBindings,
|
||||
currentBindings,
|
||||
committedCurrentBindingNames,
|
||||
) {
|
||||
const mergedBindings = new Map();
|
||||
let committedCurrentBindingCount = 0;
|
||||
|
||||
for (const binding of priorBindings) {
|
||||
mergedBindings.set(binding.name, binding.kind);
|
||||
}
|
||||
|
||||
for (const binding of currentBindings) {
|
||||
if (
|
||||
committedCurrentBindingNames.has(binding.name) ||
|
||||
canReadCommittedBinding(module, binding)
|
||||
) {
|
||||
mergedBindings.set(binding.name, binding.kind);
|
||||
committedCurrentBindingCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
bindings: Array.from(mergedBindings, ([name, kind]) => ({ name, kind })),
|
||||
committedCurrentBindingCount,
|
||||
};
|
||||
}
|
||||
|
||||
function send(message) {
|
||||
@@ -735,15 +1228,27 @@ function parseImageDetail(detail) {
|
||||
return detail;
|
||||
}
|
||||
|
||||
function normalizeEmitImageUrl(value) {
|
||||
if (typeof value !== "string" || !value) {
|
||||
throw new Error("codex.emitImage expected a non-empty image_url");
|
||||
}
|
||||
if (!/^data:/i.test(value)) {
|
||||
throw new Error("codex.emitImage only accepts data URLs");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseInputImageItem(value) {
|
||||
if (!isPlainObject(value) || value.type !== "input_image") {
|
||||
return null;
|
||||
}
|
||||
if (typeof value.image_url !== "string" || !value.image_url) {
|
||||
throw new Error("codex.emitImage expected a non-empty image_url");
|
||||
}
|
||||
return {
|
||||
images: [{ image_url: value.image_url, detail: parseImageDetail(value.detail) }],
|
||||
images: [
|
||||
{
|
||||
image_url: normalizeEmitImageUrl(value.image_url),
|
||||
detail: parseImageDetail(value.detail),
|
||||
},
|
||||
],
|
||||
textCount: 0,
|
||||
};
|
||||
}
|
||||
@@ -760,11 +1265,8 @@ function parseContentItems(items) {
|
||||
throw new Error("codex.emitImage received malformed content items");
|
||||
}
|
||||
if (item.type === "input_image") {
|
||||
if (typeof item.image_url !== "string" || !item.image_url) {
|
||||
throw new Error("codex.emitImage expected a non-empty image_url");
|
||||
}
|
||||
images.push({
|
||||
image_url: item.image_url,
|
||||
image_url: normalizeEmitImageUrl(item.image_url),
|
||||
detail: parseImageDetail(item.detail),
|
||||
});
|
||||
continue;
|
||||
@@ -815,7 +1317,7 @@ function normalizeMcpImageData(data, mimeType) {
|
||||
if (typeof data !== "string" || !data) {
|
||||
throw new Error("codex.emitImage expected MCP image data");
|
||||
}
|
||||
if (data.startsWith("data:")) {
|
||||
if (/^data:/i.test(data)) {
|
||||
return data;
|
||||
}
|
||||
const normalizedMimeType =
|
||||
@@ -882,10 +1384,7 @@ function requireSingleImage(parsed) {
|
||||
|
||||
function normalizeEmitImageValue(value) {
|
||||
if (typeof value === "string") {
|
||||
if (!value) {
|
||||
throw new Error("codex.emitImage expected a non-empty image URL");
|
||||
}
|
||||
return { image_url: value };
|
||||
return { image_url: normalizeEmitImageUrl(value) };
|
||||
}
|
||||
|
||||
const directItem = parseInputImageItem(value);
|
||||
@@ -1012,9 +1511,32 @@ async function handleExec(message) {
|
||||
};
|
||||
};
|
||||
|
||||
let module = null;
|
||||
/** @type {Binding[]} */
|
||||
let currentBindings = [];
|
||||
/** @type {Binding[]} */
|
||||
let nextBindings = [];
|
||||
/** @type {Binding[]} */
|
||||
let priorBindings = previousBindings;
|
||||
let moduleLinked = false;
|
||||
let preludeCompleted = false;
|
||||
const committedCurrentBindingNames = new Set();
|
||||
const markCommittedBindings = (...names) => {
|
||||
for (const name of names) {
|
||||
committedCurrentBindingNames.add(name);
|
||||
}
|
||||
};
|
||||
const markPreludeCompleted = () => {
|
||||
preludeCompleted = true;
|
||||
};
|
||||
|
||||
try {
|
||||
const code = typeof message.code === "string" ? message.code : "";
|
||||
const { source, nextBindings } = await buildModuleSource(code);
|
||||
const builtSource = await buildModuleSource(code);
|
||||
const source = builtSource.source;
|
||||
currentBindings = builtSource.currentBindings;
|
||||
nextBindings = builtSource.nextBindings;
|
||||
priorBindings = builtSource.priorBindings;
|
||||
let output = "";
|
||||
|
||||
context.codex = { tmpDir, tool, emitImage };
|
||||
@@ -1025,11 +1547,13 @@ async function handleExec(message) {
|
||||
process.cwd(),
|
||||
`.codex_js_repl_cell_${cellCounter++}.mjs`,
|
||||
);
|
||||
const module = new SourceTextModule(source, {
|
||||
module = new SourceTextModule(source, {
|
||||
context,
|
||||
identifier: cellIdentifier,
|
||||
initializeImportMeta(meta, mod) {
|
||||
setImportMeta(meta, mod, true);
|
||||
meta.__codexInternalMarkCommittedBindings = markCommittedBindings;
|
||||
meta.__codexInternalMarkPreludeCompleted = markPreludeCompleted;
|
||||
},
|
||||
importModuleDynamically(specifier, referrer) {
|
||||
return importResolved(resolveSpecifier(specifier, referrer?.identifier));
|
||||
@@ -1059,6 +1583,7 @@ async function handleExec(message) {
|
||||
`Top-level static import "${specifier}" is not supported in js_repl. Use await import("${specifier}") instead.`,
|
||||
);
|
||||
});
|
||||
moduleLinked = true;
|
||||
|
||||
await module.evaluate();
|
||||
if (pendingBackgroundTasks.size > 0) {
|
||||
@@ -1070,11 +1595,12 @@ async function handleExec(message) {
|
||||
throw firstUnhandledBackgroundError.error;
|
||||
}
|
||||
}
|
||||
previousModule = module;
|
||||
previousBindings = nextBindings;
|
||||
output = logs.join("\n");
|
||||
});
|
||||
|
||||
previousModule = module;
|
||||
previousBindings = nextBindings;
|
||||
|
||||
send({
|
||||
type: "exec_result",
|
||||
id: message.id,
|
||||
@@ -1083,6 +1609,29 @@ async function handleExec(message) {
|
||||
error: null,
|
||||
});
|
||||
} catch (error) {
|
||||
const { bindings: committedBindings, committedCurrentBindingCount } =
|
||||
collectCommittedBindings(
|
||||
moduleLinked ? module : null,
|
||||
priorBindings,
|
||||
currentBindings,
|
||||
committedCurrentBindingNames,
|
||||
);
|
||||
// Preserve the last successfully linked module across link-time failures.
|
||||
// A module whose link step failed cannot safely back @prev because reading
|
||||
// its namespace throws before evaluation ever begins. Likewise, if a
|
||||
// linked module failed before its prelude recreated carried bindings, keep
|
||||
// the old module so @prev still points at the last cell whose prelude and
|
||||
// body actually established the carried values. Once the prelude has run,
|
||||
// promote the failed module even if it only updated existing bindings.
|
||||
if (
|
||||
module &&
|
||||
moduleLinked &&
|
||||
(committedCurrentBindingCount > 0 ||
|
||||
(preludeCompleted && priorBindings.length > 0))
|
||||
) {
|
||||
previousModule = module;
|
||||
previousBindings = committedBindings;
|
||||
}
|
||||
send({
|
||||
type: "exec_result",
|
||||
id: message.id,
|
||||
|
||||
@@ -1134,22 +1134,31 @@ impl JsReplManager {
|
||||
let emit_id = req.id.clone();
|
||||
let response =
|
||||
if let Some(ctx) = exec_contexts.lock().await.get(&exec_id).cloned() {
|
||||
let content_item = emitted_image_content_item(
|
||||
ctx.turn.as_ref(),
|
||||
req.image_url,
|
||||
req.detail,
|
||||
);
|
||||
JsReplManager::record_exec_content_item(
|
||||
&exec_tool_calls,
|
||||
&exec_id,
|
||||
content_item,
|
||||
)
|
||||
.await;
|
||||
HostToKernel::EmitImageResult(EmitImageResult {
|
||||
id: emit_id,
|
||||
ok: true,
|
||||
error: None,
|
||||
})
|
||||
match validate_emitted_image_url(&req.image_url) {
|
||||
Ok(()) => {
|
||||
let content_item = emitted_image_content_item(
|
||||
ctx.turn.as_ref(),
|
||||
req.image_url,
|
||||
req.detail,
|
||||
);
|
||||
JsReplManager::record_exec_content_item(
|
||||
&exec_tool_calls,
|
||||
&exec_id,
|
||||
content_item,
|
||||
)
|
||||
.await;
|
||||
HostToKernel::EmitImageResult(EmitImageResult {
|
||||
id: emit_id,
|
||||
ok: true,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
Err(error) => HostToKernel::EmitImageResult(EmitImageResult {
|
||||
id: emit_id,
|
||||
ok: false,
|
||||
error: Some(error),
|
||||
}),
|
||||
}
|
||||
} else {
|
||||
HostToKernel::EmitImageResult(EmitImageResult {
|
||||
id: emit_id,
|
||||
@@ -1467,6 +1476,17 @@ fn emitted_image_content_item(
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_emitted_image_url(image_url: &str) -> Result<(), String> {
|
||||
if image_url
|
||||
.get(..5)
|
||||
.is_some_and(|scheme| scheme.eq_ignore_ascii_case("data:"))
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
Err("codex.emitImage only accepts data URLs".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn default_output_image_detail_for_turn(turn: &TurnContext) -> Option<ImageDetail> {
|
||||
(turn.config.features.enabled(Feature::ImageDetailOriginal)
|
||||
&& turn.model_info.supports_image_detail_original)
|
||||
@@ -2005,6 +2025,22 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_emitted_image_url_accepts_case_insensitive_data_scheme() {
|
||||
assert_eq!(
|
||||
validate_emitted_image_url("DATA:image/png;base64,AAA"),
|
||||
Ok(())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_emitted_image_url_rejects_non_data_scheme() {
|
||||
assert_eq!(
|
||||
validate_emitted_image_url("https://example.com/image.png"),
|
||||
Err("codex.emitImage only accepts data URLs".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_tool_call_response_for_multimodal_custom_output() {
|
||||
let response = ResponseInputItem::CustomToolCallOutput {
|
||||
@@ -2124,7 +2160,11 @@ mod tests {
|
||||
// integration tests instead.
|
||||
cfg!(target_os = "macos")
|
||||
}
|
||||
fn write_js_repl_test_package(base: &Path, name: &str, value: &str) -> anyhow::Result<()> {
|
||||
fn write_js_repl_test_package_source(
|
||||
base: &Path,
|
||||
name: &str,
|
||||
source: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let pkg_dir = base.join("node_modules").join(name);
|
||||
fs::create_dir_all(&pkg_dir)?;
|
||||
fs::write(
|
||||
@@ -2133,9 +2173,15 @@ mod tests {
|
||||
"{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"type\": \"module\",\n \"exports\": {{\n \"import\": \"./index.js\"\n }}\n}}\n"
|
||||
),
|
||||
)?;
|
||||
fs::write(
|
||||
pkg_dir.join("index.js"),
|
||||
format!("export const value = \"{value}\";\n"),
|
||||
fs::write(pkg_dir.join("index.js"), source)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_js_repl_test_package(base: &Path, name: &str, value: &str) -> anyhow::Result<()> {
|
||||
write_js_repl_test_package_source(
|
||||
base,
|
||||
name,
|
||||
&format!("export const value = \"{value}\";\n"),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -2895,6 +2941,98 @@ await codex.emitImage({ bytes: png });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn js_repl_emit_image_rejects_non_data_url() -> anyhow::Result<()> {
|
||||
if !can_run_js_repl_runtime_tests().await {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let (session, turn) = make_session_and_context().await;
|
||||
if !turn
|
||||
.model_info
|
||||
.input_modalities
|
||||
.contains(&InputModality::Image)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let session = Arc::new(session);
|
||||
let turn = Arc::new(turn);
|
||||
*session.active_turn.lock().await = Some(crate::state::ActiveTurn::default());
|
||||
|
||||
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default()));
|
||||
let manager = turn.js_repl.manager().await?;
|
||||
let code = r#"
|
||||
await codex.emitImage("https://example.com/image.png");
|
||||
"#;
|
||||
|
||||
let err = manager
|
||||
.execute(
|
||||
Arc::clone(&session),
|
||||
turn,
|
||||
tracker,
|
||||
JsReplArgs {
|
||||
code: code.to_string(),
|
||||
timeout_ms: Some(15_000),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect_err("non-data URLs should fail");
|
||||
assert!(err.to_string().contains("only accepts data URLs"));
|
||||
assert!(session.get_pending_input().await.is_empty());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn js_repl_emit_image_accepts_case_insensitive_data_url() -> anyhow::Result<()> {
|
||||
if !can_run_js_repl_runtime_tests().await {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let (session, turn) = make_session_and_context().await;
|
||||
if !turn
|
||||
.model_info
|
||||
.input_modalities
|
||||
.contains(&InputModality::Image)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let session = Arc::new(session);
|
||||
let turn = Arc::new(turn);
|
||||
*session.active_turn.lock().await = Some(crate::state::ActiveTurn::default());
|
||||
|
||||
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default()));
|
||||
let manager = turn.js_repl.manager().await?;
|
||||
let code = r#"
|
||||
await codex.emitImage("DATA:image/png;base64,AAA");
|
||||
"#;
|
||||
|
||||
let result = manager
|
||||
.execute(
|
||||
Arc::clone(&session),
|
||||
turn,
|
||||
tracker,
|
||||
JsReplArgs {
|
||||
code: code.to_string(),
|
||||
timeout_ms: Some(15_000),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
result.content_items.as_slice(),
|
||||
[FunctionCallOutputContentItem::InputImage {
|
||||
image_url: "DATA:image/png;base64,AAA".to_string(),
|
||||
detail: None,
|
||||
}]
|
||||
.as_slice()
|
||||
);
|
||||
assert!(session.get_pending_input().await.is_empty());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn js_repl_emit_image_rejects_invalid_detail() -> anyhow::Result<()> {
|
||||
if !can_run_js_repl_runtime_tests().await {
|
||||
|
||||
@@ -46,7 +46,7 @@ impl ToolCallRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(level = "trace", skip_all, fields(call = ?call))]
|
||||
#[instrument(level = "trace", skip_all)]
|
||||
pub(crate) fn handle_tool_call(
|
||||
self,
|
||||
call: ToolCall,
|
||||
|
||||
@@ -46,13 +46,23 @@ impl ApplyPatchRuntime {
|
||||
Self
|
||||
}
|
||||
|
||||
fn build_command_spec(req: &ApplyPatchRequest) -> Result<CommandSpec, ToolError> {
|
||||
use std::env;
|
||||
fn build_command_spec(
|
||||
req: &ApplyPatchRequest,
|
||||
_codex_home: &std::path::Path,
|
||||
) -> Result<CommandSpec, ToolError> {
|
||||
let exe = if let Some(path) = &req.codex_exe {
|
||||
path.clone()
|
||||
} else {
|
||||
env::current_exe()
|
||||
.map_err(|e| ToolError::Rejected(format!("failed to determine codex exe: {e}")))?
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
codex_windows_sandbox::resolve_current_exe_for_launch(_codex_home, "codex.exe")
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
std::env::current_exe().map_err(|e| {
|
||||
ToolError::Rejected(format!("failed to determine codex exe: {e}"))
|
||||
})?
|
||||
}
|
||||
};
|
||||
let program = exe.to_string_lossy().to_string();
|
||||
Ok(CommandSpec {
|
||||
@@ -159,7 +169,7 @@ impl ToolRuntime<ApplyPatchRequest, ExecToolCallOutput> for ApplyPatchRuntime {
|
||||
attempt: &SandboxAttempt<'_>,
|
||||
ctx: &ToolCtx,
|
||||
) -> Result<ExecToolCallOutput, ToolError> {
|
||||
let spec = Self::build_command_spec(req)?;
|
||||
let spec = Self::build_command_spec(req, &ctx.turn.config.codex_home)?;
|
||||
let env = attempt
|
||||
.env_for(spec, None)
|
||||
.map_err(|err| ToolError::Codex(err.into()))?;
|
||||
|
||||
@@ -10,7 +10,6 @@ use crate::sandboxing::ExecRequest;
|
||||
use crate::sandboxing::SandboxPermissions;
|
||||
use crate::shell::ShellType;
|
||||
use crate::skills::SkillMetadata;
|
||||
use crate::skills::permissions::compile_permission_profile;
|
||||
use crate::tools::runtimes::ExecveSessionApproval;
|
||||
use crate::tools::runtimes::build_command_spec;
|
||||
use crate::tools::sandboxing::SandboxAttempt;
|
||||
@@ -332,18 +331,14 @@ impl CoreShellActionProvider {
|
||||
}
|
||||
|
||||
fn skill_escalation_execution(skill: &SkillMetadata) -> EscalationExecution {
|
||||
compile_permission_profile(skill.permission_profile.clone())
|
||||
.map(|permissions| {
|
||||
EscalationExecution::Permissions(EscalationPermissions::Permissions(
|
||||
EscalatedPermissions {
|
||||
sandbox_policy: permissions.sandbox_policy.get().clone(),
|
||||
macos_seatbelt_profile_extensions: permissions
|
||||
.macos_seatbelt_profile_extensions
|
||||
.clone(),
|
||||
},
|
||||
))
|
||||
})
|
||||
.unwrap_or(EscalationExecution::TurnDefault)
|
||||
let permission_profile = skill.permission_profile.clone().unwrap_or_default();
|
||||
if permission_profile.is_empty() {
|
||||
EscalationExecution::TurnDefault
|
||||
} else {
|
||||
EscalationExecution::Permissions(EscalationPermissions::PermissionProfile(
|
||||
permission_profile,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
async fn prompt(
|
||||
@@ -741,11 +736,22 @@ struct CoreShellCommandExecutor {
|
||||
justification: Option<String>,
|
||||
arg0: Option<String>,
|
||||
sandbox_policy_cwd: PathBuf,
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
macos_seatbelt_profile_extensions: Option<MacOsSeatbeltProfileExtensions>,
|
||||
codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
use_linux_sandbox_bwrap: bool,
|
||||
}
|
||||
|
||||
struct PrepareSandboxedExecParams<'a> {
|
||||
command: Vec<String>,
|
||||
workdir: &'a AbsolutePathBuf,
|
||||
env: HashMap<String, String>,
|
||||
sandbox_policy: &'a SandboxPolicy,
|
||||
additional_permissions: Option<PermissionProfile>,
|
||||
#[cfg(target_os = "macos")]
|
||||
macos_seatbelt_profile_extensions: Option<&'a MacOsSeatbeltProfileExtensions>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ShellCommandExecutor for CoreShellCommandExecutor {
|
||||
async fn run(
|
||||
@@ -816,33 +822,49 @@ impl ShellCommandExecutor for CoreShellCommandExecutor {
|
||||
env,
|
||||
arg0: Some(first_arg.clone()),
|
||||
},
|
||||
EscalationExecution::TurnDefault => self.prepare_sandboxed_exec(
|
||||
command,
|
||||
workdir,
|
||||
env,
|
||||
&self.sandbox_policy,
|
||||
None,
|
||||
self.macos_seatbelt_profile_extensions.as_ref(),
|
||||
)?,
|
||||
EscalationExecution::Permissions(EscalationPermissions::PermissionProfile(
|
||||
permission_profile,
|
||||
)) => self.prepare_sandboxed_exec(
|
||||
command,
|
||||
workdir,
|
||||
env,
|
||||
&self.sandbox_policy,
|
||||
Some(permission_profile),
|
||||
None,
|
||||
)?,
|
||||
EscalationExecution::Permissions(EscalationPermissions::Permissions(permissions)) => {
|
||||
self.prepare_sandboxed_exec(
|
||||
EscalationExecution::TurnDefault => {
|
||||
self.prepare_sandboxed_exec(PrepareSandboxedExecParams {
|
||||
command,
|
||||
workdir,
|
||||
env,
|
||||
&permissions.sandbox_policy,
|
||||
None,
|
||||
permissions.macos_seatbelt_profile_extensions.as_ref(),
|
||||
)?
|
||||
sandbox_policy: &self.sandbox_policy,
|
||||
additional_permissions: None,
|
||||
#[cfg(target_os = "macos")]
|
||||
macos_seatbelt_profile_extensions: self
|
||||
.macos_seatbelt_profile_extensions
|
||||
.as_ref(),
|
||||
})?
|
||||
}
|
||||
EscalationExecution::Permissions(EscalationPermissions::PermissionProfile(
|
||||
permission_profile,
|
||||
)) => {
|
||||
// Merge additive permissions into the existing turn/request sandbox policy.
|
||||
// On macOS, additional profile extensions are unioned with the turn defaults.
|
||||
self.prepare_sandboxed_exec(PrepareSandboxedExecParams {
|
||||
command,
|
||||
workdir,
|
||||
env,
|
||||
sandbox_policy: &self.sandbox_policy,
|
||||
additional_permissions: Some(permission_profile),
|
||||
#[cfg(target_os = "macos")]
|
||||
macos_seatbelt_profile_extensions: self
|
||||
.macos_seatbelt_profile_extensions
|
||||
.as_ref(),
|
||||
})?
|
||||
}
|
||||
EscalationExecution::Permissions(EscalationPermissions::Permissions(permissions)) => {
|
||||
// Use a fully specified sandbox policy instead of merging into the turn policy.
|
||||
self.prepare_sandboxed_exec(PrepareSandboxedExecParams {
|
||||
command,
|
||||
workdir,
|
||||
env,
|
||||
sandbox_policy: &permissions.sandbox_policy,
|
||||
additional_permissions: None,
|
||||
#[cfg(target_os = "macos")]
|
||||
macos_seatbelt_profile_extensions: permissions
|
||||
.macos_seatbelt_profile_extensions
|
||||
.as_ref(),
|
||||
})?
|
||||
}
|
||||
};
|
||||
|
||||
@@ -853,18 +875,17 @@ impl ShellCommandExecutor for CoreShellCommandExecutor {
|
||||
impl CoreShellCommandExecutor {
|
||||
fn prepare_sandboxed_exec(
|
||||
&self,
|
||||
command: Vec<String>,
|
||||
workdir: &AbsolutePathBuf,
|
||||
env: HashMap<String, String>,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
additional_permissions: Option<PermissionProfile>,
|
||||
#[cfg(target_os = "macos")] macos_seatbelt_profile_extensions: Option<
|
||||
&MacOsSeatbeltProfileExtensions,
|
||||
>,
|
||||
#[cfg(not(target_os = "macos"))] _macos_seatbelt_profile_extensions: Option<
|
||||
&MacOsSeatbeltProfileExtensions,
|
||||
>,
|
||||
params: PrepareSandboxedExecParams<'_>,
|
||||
) -> anyhow::Result<PreparedExec> {
|
||||
let PrepareSandboxedExecParams {
|
||||
command,
|
||||
workdir,
|
||||
env,
|
||||
sandbox_policy,
|
||||
additional_permissions,
|
||||
#[cfg(target_os = "macos")]
|
||||
macos_seatbelt_profile_extensions,
|
||||
} = params;
|
||||
let (program, args) = command
|
||||
.split_first()
|
||||
.ok_or_else(|| anyhow::anyhow!("prepared command must not be empty"))?;
|
||||
|
||||
@@ -20,6 +20,7 @@ use crate::protocol::SandboxPolicy;
|
||||
use crate::sandboxing::SandboxPermissions;
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE;
|
||||
use crate::skills::SkillMetadata;
|
||||
use codex_execpolicy::Decision;
|
||||
use codex_execpolicy::Evaluation;
|
||||
use codex_execpolicy::PolicyParser;
|
||||
@@ -30,6 +31,7 @@ use codex_protocol::models::FileSystemPermissions;
|
||||
use codex_protocol::models::MacOsPreferencesPermission;
|
||||
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use codex_shell_escalation::EscalationExecution;
|
||||
use codex_shell_escalation::EscalationPermissions;
|
||||
use codex_shell_escalation::ExecResult;
|
||||
@@ -59,6 +61,20 @@ fn starlark_string(value: &str) -> String {
|
||||
value.replace('\\', "\\\\").replace('"', "\\\"")
|
||||
}
|
||||
|
||||
fn test_skill_metadata(permission_profile: Option<PermissionProfile>) -> SkillMetadata {
|
||||
SkillMetadata {
|
||||
name: "skill".to_string(),
|
||||
description: "description".to_string(),
|
||||
short_description: None,
|
||||
interface: None,
|
||||
dependencies: None,
|
||||
policy: None,
|
||||
permission_profile,
|
||||
path_to_skills_md: PathBuf::from("/tmp/skill/SKILL.md"),
|
||||
scope: SkillScope::User,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_shell_script_preserves_login_flag() {
|
||||
assert_eq!(
|
||||
@@ -246,6 +262,42 @@ fn shell_request_escalation_execution_is_explicit() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_escalation_execution_uses_additional_permissions() {
|
||||
let requested_permissions = PermissionProfile {
|
||||
file_system: Some(FileSystemPermissions {
|
||||
read: None,
|
||||
write: Some(vec![
|
||||
AbsolutePathBuf::from_absolute_path("/tmp/output").unwrap(),
|
||||
]),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
CoreShellActionProvider::skill_escalation_execution(&test_skill_metadata(Some(
|
||||
requested_permissions.clone(),
|
||||
))),
|
||||
EscalationExecution::Permissions(EscalationPermissions::PermissionProfile(
|
||||
requested_permissions,
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_escalation_execution_ignores_empty_permissions() {
|
||||
assert_eq!(
|
||||
CoreShellActionProvider::skill_escalation_execution(&test_skill_metadata(Some(
|
||||
PermissionProfile::default(),
|
||||
))),
|
||||
EscalationExecution::TurnDefault,
|
||||
);
|
||||
assert_eq!(
|
||||
CoreShellActionProvider::skill_escalation_execution(&test_skill_metadata(None)),
|
||||
EscalationExecution::TurnDefault,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluate_intercepted_exec_policy_uses_wrapper_command_when_shell_wrapper_parsing_disabled() {
|
||||
let policy_src = r#"prefix_rule(pattern = ["npm", "publish"], decision = "prompt")"#;
|
||||
@@ -527,3 +579,67 @@ async fn prepare_escalated_exec_permissions_preserve_macos_seatbelt_extensions()
|
||||
prepared.command
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tokio::test]
|
||||
async fn prepare_escalated_exec_permission_profile_unions_turn_and_requested_macos_extensions() {
|
||||
let cwd = AbsolutePathBuf::from_absolute_path(std::env::temp_dir()).unwrap();
|
||||
let executor = CoreShellCommandExecutor {
|
||||
command: vec!["echo".to_string(), "ok".to_string()],
|
||||
cwd: cwd.to_path_buf(),
|
||||
env: HashMap::new(),
|
||||
network: None,
|
||||
sandbox: SandboxType::None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
justification: None,
|
||||
arg0: None,
|
||||
sandbox_policy_cwd: cwd.to_path_buf(),
|
||||
macos_seatbelt_profile_extensions: Some(MacOsSeatbeltProfileExtensions {
|
||||
macos_preferences: MacOsPreferencesPermission::ReadOnly,
|
||||
..Default::default()
|
||||
}),
|
||||
codex_linux_sandbox_exe: None,
|
||||
use_linux_sandbox_bwrap: false,
|
||||
};
|
||||
|
||||
let prepared = executor
|
||||
.prepare_escalated_exec(
|
||||
&AbsolutePathBuf::from_absolute_path("/bin/echo").unwrap(),
|
||||
&["echo".to_string(), "ok".to_string()],
|
||||
&cwd,
|
||||
HashMap::new(),
|
||||
EscalationExecution::Permissions(EscalationPermissions::PermissionProfile(
|
||||
PermissionProfile {
|
||||
macos: Some(MacOsSeatbeltProfileExtensions {
|
||||
macos_calendar: true,
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let policy = prepared
|
||||
.command
|
||||
.get(2)
|
||||
.expect("seatbelt policy should be present");
|
||||
assert_eq!(
|
||||
prepared.command.first().map(String::as_str),
|
||||
Some(MACOS_PATH_TO_SEATBELT_EXECUTABLE)
|
||||
);
|
||||
assert_eq!(prepared.command.get(1).map(String::as_str), Some("-p"));
|
||||
assert!(
|
||||
policy.contains("(allow user-preference-read)"),
|
||||
"expected turn macOS seatbelt extensions to be preserved: {:?}",
|
||||
prepared.command
|
||||
);
|
||||
assert!(
|
||||
policy.contains("(allow mach-lookup (global-name \"com.apple.CalendarAgent\"))"),
|
||||
"expected requested macOS seatbelt extensions to be included: {:?}",
|
||||
prepared.command
|
||||
);
|
||||
}
|
||||
|
||||
+105
-27
@@ -253,7 +253,7 @@ fn create_approval_parameters(request_permission_enabled: bool) -> BTreeMap<Stri
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
if request_permission_enabled {
|
||||
"Sandbox permissions for the command. Use \"with_additional_permissions\" to request additional sandboxed filesystem access (preferred), or \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\"."
|
||||
"Sandbox permissions for the command. Use \"with_additional_permissions\" to request additional sandboxed filesystem, network, or macOS permissions (preferred), or \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\"."
|
||||
} else {
|
||||
"Sandbox permissions for the command. Set to \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\"."
|
||||
}
|
||||
@@ -291,36 +291,100 @@ fn create_approval_parameters(request_permission_enabled: bool) -> BTreeMap<Stri
|
||||
properties.insert(
|
||||
"additional_permissions".to_string(),
|
||||
JsonSchema::Object {
|
||||
properties: BTreeMap::from([(
|
||||
"file_system".to_string(),
|
||||
JsonSchema::Object {
|
||||
properties: BTreeMap::from([
|
||||
(
|
||||
"read".to_string(),
|
||||
JsonSchema::Array {
|
||||
items: Box::new(JsonSchema::String { description: None }),
|
||||
properties: BTreeMap::from([
|
||||
(
|
||||
"network".to_string(),
|
||||
JsonSchema::Object {
|
||||
properties: BTreeMap::from([(
|
||||
"enabled".to_string(),
|
||||
JsonSchema::Boolean {
|
||||
description: Some(
|
||||
"Additional filesystem paths to grant read access for this command."
|
||||
"Set to true to enable network access for this command."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"write".to_string(),
|
||||
JsonSchema::Array {
|
||||
items: Box::new(JsonSchema::String { description: None }),
|
||||
description: Some(
|
||||
"Additional filesystem paths to grant write access for this command."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
]),
|
||||
required: None,
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
)]),
|
||||
required: Some(vec!["file_system".to_string()]),
|
||||
)]),
|
||||
required: None,
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
),
|
||||
(
|
||||
"file_system".to_string(),
|
||||
JsonSchema::Object {
|
||||
properties: BTreeMap::from([
|
||||
(
|
||||
"read".to_string(),
|
||||
JsonSchema::Array {
|
||||
items: Box::new(JsonSchema::String { description: None }),
|
||||
description: Some(
|
||||
"Additional filesystem paths to grant read access for this command."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"write".to_string(),
|
||||
JsonSchema::Array {
|
||||
items: Box::new(JsonSchema::String { description: None }),
|
||||
description: Some(
|
||||
"Additional filesystem paths to grant write access for this command."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
]),
|
||||
required: None,
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
),
|
||||
(
|
||||
"macos".to_string(),
|
||||
JsonSchema::Object {
|
||||
properties: BTreeMap::from([
|
||||
(
|
||||
"preferences".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"Additional macOS preferences access for this command. Supported values: \"readonly\" or \"readwrite\"."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"automations".to_string(),
|
||||
JsonSchema::Array {
|
||||
items: Box::new(JsonSchema::String { description: None }),
|
||||
description: Some(
|
||||
"Additional macOS automation targets for this command as bundle IDs, or use true in clients that support boolean union payloads."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"accessibility".to_string(),
|
||||
JsonSchema::Boolean {
|
||||
description: Some(
|
||||
"Set to true to allow macOS accessibility APIs for this command."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"calendar".to_string(),
|
||||
JsonSchema::Boolean {
|
||||
description: Some(
|
||||
"Set to true to allow macOS Calendar access for this command."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
]),
|
||||
required: None,
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
),
|
||||
]),
|
||||
required: None,
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
);
|
||||
@@ -3076,6 +3140,7 @@ mod tests {
|
||||
),
|
||||
connector_id: Some("calendar".to_string()),
|
||||
connector_name: Some("Calendar".to_string()),
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
),
|
||||
(
|
||||
@@ -3086,6 +3151,7 @@ mod tests {
|
||||
tool: mcp_tool("echo", "Echo", serde_json::json!({"type": "object"})),
|
||||
connector_id: None,
|
||||
connector_name: None,
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
),
|
||||
])),
|
||||
@@ -3364,6 +3430,18 @@ Examples of valid command strings:
|
||||
panic!("expected sandbox_permissions description");
|
||||
};
|
||||
assert!(description.contains("with_additional_permissions"));
|
||||
assert!(description.contains("macOS permissions"));
|
||||
|
||||
let Some(JsonSchema::Object {
|
||||
properties: additional_properties,
|
||||
..
|
||||
}) = properties.get("additional_permissions")
|
||||
else {
|
||||
panic!("expected additional_permissions schema");
|
||||
};
|
||||
assert!(additional_properties.contains_key("network"));
|
||||
assert!(additional_properties.contains_key("file_system"));
|
||||
assert!(additional_properties.contains_key("macos"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -20,10 +20,15 @@ use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::WarningEvent;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use core_test_support::context_snapshot;
|
||||
use core_test_support::context_snapshot::ContextSnapshotOptions;
|
||||
use core_test_support::context_snapshot::ContextSnapshotRenderMode;
|
||||
use core_test_support::responses::ResponseMock;
|
||||
use core_test_support::responses::ResponsesRequest;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
use core_test_support::responses::ev_completed;
|
||||
use core_test_support::responses::mount_sse_once_match;
|
||||
use core_test_support::responses::mount_sse_sequence;
|
||||
use core_test_support::responses::sse;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event;
|
||||
@@ -35,6 +40,7 @@ use tempfile::TempDir;
|
||||
use wiremock::MockServer;
|
||||
|
||||
const AFTER_SECOND_RESUME: &str = "AFTER_SECOND_RESUME";
|
||||
const AFTER_ROLLBACK: &str = "AFTER_ROLLBACK";
|
||||
|
||||
fn network_disabled() -> bool {
|
||||
std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok()
|
||||
@@ -406,6 +412,95 @@ async fn compact_resume_after_second_compaction_preserves_history() -> Result<()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
/// Scenario: rolling back behind a pre-turn compaction should replay
|
||||
/// append-only history from the rollout file and keep earlier compacted
|
||||
/// history visible.
|
||||
async fn snapshot_rollback_past_compaction_replays_append_only_history() -> Result<()> {
|
||||
if network_disabled() {
|
||||
println!("Skipping test because network is disabled in this sandbox");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
const EDITED_AFTER_COMPACT: &str = "EDITED_AFTER_COMPACT";
|
||||
const SECOND_REPLY: &str = "SECOND_REPLY";
|
||||
|
||||
let server = MockServer::start().await;
|
||||
let sse1 = sse(vec![
|
||||
ev_assistant_message("m1", FIRST_REPLY),
|
||||
ev_completed("r1"),
|
||||
]);
|
||||
let sse2 = sse(vec![
|
||||
ev_assistant_message("m2", SUMMARY_TEXT),
|
||||
ev_completed("r2"),
|
||||
]);
|
||||
let sse3 = sse(vec![
|
||||
ev_assistant_message("m3", SECOND_REPLY),
|
||||
ev_completed("r3"),
|
||||
]);
|
||||
let sse4 = sse(vec![ev_completed("r4")]);
|
||||
|
||||
let request_log = mount_sse_sequence(&server, vec![sse1, sse2, sse3, sse4]).await;
|
||||
|
||||
let (_home, _config, _manager, base) = start_test_conversation(&server, None).await;
|
||||
|
||||
user_turn(&base, "hello world").await;
|
||||
compact_conversation(&base).await;
|
||||
user_turn(&base, EDITED_AFTER_COMPACT).await;
|
||||
|
||||
base.submit(Op::ThreadRollback { num_turns: 1 })
|
||||
.await
|
||||
.expect("submit thread rollback");
|
||||
let rollback_event =
|
||||
wait_for_event(&base, |ev| matches!(ev, EventMsg::ThreadRolledBack(_))).await;
|
||||
let EventMsg::ThreadRolledBack(rollback_event) = rollback_event else {
|
||||
panic!("expected thread rolled back event");
|
||||
};
|
||||
assert_eq!(rollback_event.num_turns, 1);
|
||||
|
||||
user_turn(&base, AFTER_ROLLBACK).await;
|
||||
|
||||
let requests = request_log.requests();
|
||||
assert_eq!(requests.len(), 4);
|
||||
assert!(requests[1].body_contains_text(SUMMARIZATION_PROMPT));
|
||||
assert!(requests[2].body_contains_text("hello world"));
|
||||
assert!(requests[2].body_contains_text(SUMMARY_TEXT));
|
||||
assert!(requests[2].body_contains_text(EDITED_AFTER_COMPACT));
|
||||
let after_rollback_user_texts = requests[3].message_input_texts("user");
|
||||
let after_rollback_last = after_rollback_user_texts
|
||||
.last()
|
||||
.unwrap_or_else(|| panic!("post-rollback request missing user messages"));
|
||||
assert_eq!(after_rollback_last, AFTER_ROLLBACK);
|
||||
assert!(
|
||||
requests[3].body_contains_text("hello world"),
|
||||
"the first turn should remain visible after rollback behind compaction",
|
||||
);
|
||||
assert!(
|
||||
!requests[3].body_contains_text(EDITED_AFTER_COMPACT),
|
||||
"the edited post-compaction turn should be removed by rollback",
|
||||
);
|
||||
assert!(
|
||||
requests[3].body_contains_text(SUMMARY_TEXT),
|
||||
"compaction summary should remain for the preserved first turn",
|
||||
);
|
||||
|
||||
insta::assert_snapshot!(
|
||||
"rollback_past_compaction_shapes",
|
||||
context_snapshot::format_labeled_requests_snapshot(
|
||||
"rollback past compaction replay after rollback",
|
||||
&[
|
||||
("compaction request", &requests[1]),
|
||||
("before rollback", &requests[2]),
|
||||
("after rollback", &requests[3]),
|
||||
],
|
||||
&ContextSnapshotOptions::default()
|
||||
.render_mode(ContextSnapshotRenderMode::KindWithTextPrefix { max_chars: 64 }),
|
||||
)
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalize_line_endings(value: &mut Value) {
|
||||
match value {
|
||||
Value::String(text) => {
|
||||
@@ -427,10 +522,16 @@ fn normalize_line_endings(value: &mut Value) {
|
||||
}
|
||||
}
|
||||
|
||||
fn gather_request_bodies(request_log: &[ResponseMock]) -> Vec<Value> {
|
||||
let mut bodies = request_log
|
||||
fn gather_requests(request_log: &[ResponseMock]) -> Vec<ResponsesRequest> {
|
||||
request_log
|
||||
.iter()
|
||||
.flat_map(ResponseMock::requests)
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
|
||||
fn gather_request_bodies(request_log: &[ResponseMock]) -> Vec<Value> {
|
||||
let mut bodies = gather_requests(request_log)
|
||||
.into_iter()
|
||||
.map(|request| request.body_json())
|
||||
.collect::<Vec<_>>();
|
||||
bodies.iter_mut().for_each(normalize_line_endings);
|
||||
|
||||
@@ -31,6 +31,22 @@ fn custom_tool_output_text_and_success(
|
||||
(output.unwrap_or_default(), success)
|
||||
}
|
||||
|
||||
fn assert_js_repl_ok(req: &ResponsesRequest, call_id: &str, expected_output: &str) {
|
||||
let (output, success) = custom_tool_output_text_and_success(req, call_id);
|
||||
assert_ne!(
|
||||
success,
|
||||
Some(false),
|
||||
"js_repl call failed unexpectedly: {output}"
|
||||
);
|
||||
assert!(output.contains(expected_output), "output was: {output}");
|
||||
}
|
||||
|
||||
fn assert_js_repl_err(req: &ResponsesRequest, call_id: &str, expected_output: &str) {
|
||||
let (output, success) = custom_tool_output_text_and_success(req, call_id);
|
||||
assert_ne!(success, Some(true), "js_repl call should fail: {output}");
|
||||
assert!(output.contains(expected_output), "output was: {output}");
|
||||
}
|
||||
|
||||
fn tool_names(body: &serde_json::Value) -> Vec<String> {
|
||||
body["tools"]
|
||||
.as_array()
|
||||
@@ -75,6 +91,22 @@ async fn run_js_repl_turn(
|
||||
prompt: &str,
|
||||
calls: &[(&str, &str)],
|
||||
) -> Result<ResponseMock> {
|
||||
let mut mocks = run_js_repl_sequence(server, prompt, calls).await?;
|
||||
Ok(mocks
|
||||
.pop()
|
||||
.expect("js_repl test should return a request mock"))
|
||||
}
|
||||
|
||||
async fn run_js_repl_sequence(
|
||||
server: &MockServer,
|
||||
prompt: &str,
|
||||
calls: &[(&str, &str)],
|
||||
) -> Result<Vec<ResponseMock>> {
|
||||
anyhow::ensure!(
|
||||
!calls.is_empty(),
|
||||
"js_repl test must include at least one call"
|
||||
);
|
||||
|
||||
let mut builder = test_codex().with_config(|config| {
|
||||
config
|
||||
.features
|
||||
@@ -83,24 +115,68 @@ async fn run_js_repl_turn(
|
||||
});
|
||||
let test = builder.build(server).await?;
|
||||
|
||||
let mut first_events = vec![ev_response_created("resp-1")];
|
||||
for (call_id, js_input) in calls {
|
||||
first_events.push(ev_custom_tool_call(call_id, "js_repl", js_input));
|
||||
}
|
||||
first_events.push(ev_completed("resp-1"));
|
||||
responses::mount_sse_once(server, sse(first_events)).await;
|
||||
|
||||
let second_mock = responses::mount_sse_once(
|
||||
responses::mount_sse_once(
|
||||
server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-2"),
|
||||
ev_response_created("resp-1"),
|
||||
ev_custom_tool_call(calls[0].0, "js_repl", calls[0].1),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut mocks = Vec::with_capacity(calls.len());
|
||||
for (response_index, (call_id, js_input)) in calls.iter().enumerate().skip(1) {
|
||||
let response_id = format!("resp-{}", response_index + 1);
|
||||
let mock = responses::mount_sse_once(
|
||||
server,
|
||||
sse(vec![
|
||||
ev_response_created(&response_id),
|
||||
ev_custom_tool_call(call_id, "js_repl", js_input),
|
||||
ev_completed(&response_id),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
mocks.push(mock);
|
||||
}
|
||||
|
||||
let final_response_id = format!("resp-{}", calls.len() + 1);
|
||||
let final_mock = responses::mount_sse_once(
|
||||
server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed(&final_response_id),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
mocks.push(final_mock);
|
||||
|
||||
test.submit_turn(prompt).await?;
|
||||
Ok(second_mock)
|
||||
Ok(mocks)
|
||||
}
|
||||
|
||||
async fn assert_failed_cell_followup(
|
||||
server: &MockServer,
|
||||
prompt: &str,
|
||||
failing_cell: &str,
|
||||
followup_cell: &str,
|
||||
expected_followup_output: &str,
|
||||
) -> Result<()> {
|
||||
let mocks = run_js_repl_sequence(
|
||||
server,
|
||||
prompt,
|
||||
&[("call-1", failing_cell), ("call-2", followup_cell)],
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_js_repl_err(&mocks[0].single_request(), "call-1", "boom");
|
||||
assert_js_repl_ok(
|
||||
&mocks[1].single_request(),
|
||||
"call-2",
|
||||
expected_followup_output,
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
@@ -165,68 +241,328 @@ async fn js_repl_is_not_advertised_when_startup_node_is_incompatible() -> Result
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn js_repl_persists_top_level_bindings_and_supports_tla() -> Result<()> {
|
||||
async fn js_repl_persists_top_level_destructured_bindings_and_supports_tla() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let mut builder = test_codex().with_config(|config| {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::JsRepl)
|
||||
.expect("test config should allow feature update");
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
responses::mount_sse_once(
|
||||
let mocks = run_js_repl_sequence(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_custom_tool_call(
|
||||
"run js_repl twice",
|
||||
&[
|
||||
(
|
||||
"call-1",
|
||||
"js_repl",
|
||||
"let x = await Promise.resolve(41); console.log(x);",
|
||||
"const { context: liveContext, session } = await Promise.resolve({ context: 41, session: 1 }); console.log(liveContext + session);",
|
||||
),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
("call-2", "console.log(liveContext + session);"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let second_mock = responses::mount_sse_once(
|
||||
.await?;
|
||||
|
||||
assert_js_repl_ok(&mocks[0].single_request(), "call-1", "42");
|
||||
assert_js_repl_ok(&mocks[1].single_request(), "call-2", "42");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn js_repl_failed_cells_commit_initialized_bindings_only() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let mocks = run_js_repl_sequence(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-2"),
|
||||
ev_custom_tool_call("call-2", "js_repl", "console.log(x + 1);"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
"run js_repl across a failed cell",
|
||||
&[
|
||||
("call-1", "const base = 40; console.log(base);"),
|
||||
(
|
||||
"call-2",
|
||||
"const { session } = await Promise.resolve({ session: 2 }); throw new Error(\"boom\"); const late = 99;",
|
||||
),
|
||||
("call-3", "console.log(base + session, typeof late);"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let third_mock = responses::mount_sse_once(
|
||||
.await?;
|
||||
|
||||
assert_js_repl_ok(&mocks[0].single_request(), "call-1", "40");
|
||||
assert_js_repl_err(&mocks[1].single_request(), "call-2", "boom");
|
||||
assert_js_repl_ok(&mocks[2].single_request(), "call-3", "42 undefined");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn js_repl_failed_cells_preserve_initialized_lexical_destructuring_bindings() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let mocks = run_js_repl_sequence(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-3"),
|
||||
]),
|
||||
"run js_repl through partial destructuring failure",
|
||||
&[
|
||||
(
|
||||
"call-1",
|
||||
"const { a, b } = { a: 1, get b() { throw new Error(\"boom\"); } };",
|
||||
),
|
||||
(
|
||||
"call-2",
|
||||
"let aValue; try { aValue = a; } catch (error) { aValue = error.name; } let bValue; try { bValue = b; } catch (error) { bValue = error.name; } console.log(aValue, bValue);",
|
||||
),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
.await?;
|
||||
|
||||
test.submit_turn("run js_repl twice").await?;
|
||||
assert_js_repl_err(&mocks[0].single_request(), "call-1", "boom");
|
||||
assert_js_repl_ok(&mocks[1].single_request(), "call-2", "1 ReferenceError");
|
||||
|
||||
let req2 = second_mock.single_request();
|
||||
let (first_output, first_success) = custom_tool_output_text_and_success(&req2, "call-1");
|
||||
assert_ne!(
|
||||
first_success,
|
||||
Some(false),
|
||||
"first js_repl call failed unexpectedly: {first_output}"
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn js_repl_link_failures_keep_prior_module_state() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let mocks = run_js_repl_sequence(
|
||||
&server,
|
||||
"run js_repl across a link failure",
|
||||
&[
|
||||
("call-1", "const answer = 41; console.log(answer);"),
|
||||
("call-2", "import value from \"./foo\";"),
|
||||
("call-3", "console.log(answer + 1);"),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_js_repl_ok(&mocks[0].single_request(), "call-1", "41");
|
||||
assert_js_repl_err(
|
||||
&mocks[1].single_request(),
|
||||
"call-2",
|
||||
"Top-level static import \"./foo\" is not supported in js_repl",
|
||||
);
|
||||
assert!(first_output.contains("41"));
|
||||
assert_js_repl_ok(&mocks[2].single_request(), "call-3", "42");
|
||||
|
||||
let req3 = third_mock.single_request();
|
||||
let (second_output, second_success) = custom_tool_output_text_and_success(&req3, "call-2");
|
||||
assert_ne!(
|
||||
second_success,
|
||||
Some(false),
|
||||
"second js_repl call failed unexpectedly: {second_output}"
|
||||
);
|
||||
assert!(second_output.contains("42"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn js_repl_failed_cells_do_not_commit_unreached_hoisted_bindings() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let mocks = run_js_repl_sequence(
|
||||
&server,
|
||||
"run js_repl through hoisted binding failure",
|
||||
&[
|
||||
(
|
||||
"call-1",
|
||||
"var early = 1; throw new Error(\"boom\"); var late = 2; function fn() { return 1; }",
|
||||
),
|
||||
(
|
||||
"call-2",
|
||||
"const late = 40; const fn = 1; console.log(early + late + fn);",
|
||||
),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_js_repl_err(&mocks[0].single_request(), "call-1", "boom");
|
||||
assert_js_repl_ok(&mocks[1].single_request(), "call-2", "42");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn js_repl_failed_cells_do_not_preserve_hoisted_function_reads_before_declaration()
|
||||
-> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let mocks = run_js_repl_sequence(
|
||||
&server,
|
||||
"run js_repl through unsupported hoisted function reads",
|
||||
&[
|
||||
(
|
||||
"call-1",
|
||||
"foo(); throw new Error(\"boom\"); function foo() {}",
|
||||
),
|
||||
(
|
||||
"call-2",
|
||||
"let value; try { foo; value = \"present\"; } catch (error) { value = error.name; } console.log(value);",
|
||||
),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_js_repl_err(&mocks[0].single_request(), "call-1", "boom");
|
||||
assert_js_repl_ok(&mocks[1].single_request(), "call-2", "ReferenceError");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn js_repl_failed_cells_preserve_functions_when_declaration_sites_are_reached() -> Result<()>
|
||||
{
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let mocks = run_js_repl_sequence(
|
||||
&server,
|
||||
"run js_repl through supported function declaration persistence",
|
||||
&[
|
||||
("call-1", "function foo() {} throw new Error(\"boom\");"),
|
||||
("call-2", "console.log(typeof foo);"),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_js_repl_err(&mocks[0].single_request(), "call-1", "boom");
|
||||
assert_js_repl_ok(&mocks[1].single_request(), "call-2", "function");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn js_repl_failed_cells_preserve_prior_binding_writes_without_new_bindings() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let mocks = run_js_repl_sequence(
|
||||
&server,
|
||||
"run js_repl through failed prior-binding writes",
|
||||
&[
|
||||
("call-1", "let x = 1; console.log(x);"),
|
||||
("call-2", "x = 2; throw new Error(\"boom\");"),
|
||||
("call-3", "console.log(x);"),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_js_repl_ok(&mocks[0].single_request(), "call-1", "1");
|
||||
assert_js_repl_err(&mocks[1].single_request(), "call-2", "boom");
|
||||
assert_js_repl_ok(&mocks[2].single_request(), "call-3", "2");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn js_repl_failed_cells_var_persistence_boundaries() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let cases = [
|
||||
(
|
||||
"run js_repl through supported pre-declaration var writes",
|
||||
"x = 5; y = 1; y += 2; z = 1; z++; throw new Error(\"boom\"); var x, y, z;",
|
||||
"console.log(x, y, z);",
|
||||
"5 3 2",
|
||||
),
|
||||
(
|
||||
"run js_repl through short-circuited logical var assignments",
|
||||
"x &&= 1; y ||= 2; z ??= 3; throw new Error(\"boom\"); var x, y, z;",
|
||||
"let xValue; try { xValue = x; } catch (error) { xValue = error.name; } console.log(xValue, y, z);",
|
||||
"ReferenceError 2 3",
|
||||
),
|
||||
(
|
||||
"run js_repl through unsupported shadowed nested var writes",
|
||||
"{ let x = 1; x = 2; } throw new Error(\"boom\"); var x;",
|
||||
"let value; try { value = x; } catch (error) { value = error.name; } console.log(value);",
|
||||
"ReferenceError",
|
||||
),
|
||||
(
|
||||
"run js_repl through unsupported nested assignment writes",
|
||||
"x = (y = 1); throw new Error(\"boom\"); var x, y;",
|
||||
"let yValue; try { yValue = y; } catch (error) { yValue = error.name; } console.log(x, yValue);",
|
||||
"1 ReferenceError",
|
||||
),
|
||||
(
|
||||
"run js_repl through unsupported var destructuring recovery",
|
||||
"var { a, b } = { a: 1, get b() { throw new Error(\"boom\"); } };",
|
||||
"let aValue; try { aValue = a; } catch (error) { aValue = error.name; } let bValue; try { bValue = b; } catch (error) { bValue = error.name; } console.log(aValue, bValue);",
|
||||
"ReferenceError ReferenceError",
|
||||
),
|
||||
];
|
||||
|
||||
for (prompt, failing_cell, followup_cell, expected_followup_output) in cases {
|
||||
assert_failed_cell_followup(
|
||||
&server,
|
||||
prompt,
|
||||
failing_cell,
|
||||
followup_cell,
|
||||
expected_followup_output,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn js_repl_failed_cells_commit_non_empty_loop_vars_but_skip_empty_loops() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let mocks = run_js_repl_sequence(
|
||||
&server,
|
||||
"run js_repl through failed loop bindings",
|
||||
&[
|
||||
(
|
||||
"call-1",
|
||||
"for (var item of [2]) {} for (var emptyItem of []) {} throw new Error(\"boom\");",
|
||||
),
|
||||
(
|
||||
"call-2",
|
||||
"let itemValue; try { itemValue = item; } catch (error) { itemValue = error.name; } let emptyValue; try { emptyValue = emptyItem; } catch (error) { emptyValue = error.name; } console.log(itemValue, emptyValue);",
|
||||
),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_js_repl_err(&mocks[0].single_request(), "call-1", "boom");
|
||||
assert_js_repl_ok(&mocks[1].single_request(), "call-2", "2 ReferenceError");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn js_repl_keeps_function_to_string_stable() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let mock = run_js_repl_turn(
|
||||
&server,
|
||||
"run js_repl through function toString",
|
||||
&[(
|
||||
"call-1",
|
||||
"function foo() { return 1; } console.log(foo.toString());",
|
||||
)],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let req = mock.single_request();
|
||||
assert_js_repl_ok(&req, "call-1", "function foo() { return 1; }");
|
||||
let (output, _) = custom_tool_output_text_and_success(&req, "call-1");
|
||||
assert!(!output.contains("__codexInternalMarkCommittedBindings"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn js_repl_allows_globalthis_shadowing_with_instrumented_bindings() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let mock = run_js_repl_turn(
|
||||
&server,
|
||||
"run js_repl with shadowed globalThis",
|
||||
&[(
|
||||
"call-1",
|
||||
"const globalThis = {}; const value = 1; console.log(typeof globalThis, value);",
|
||||
)],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let req = mock.single_request();
|
||||
assert_js_repl_ok(&req, "call-1", "object 1");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ use core_test_support::apps_test_server::AppsTestServer;
|
||||
use core_test_support::responses::ev_completed;
|
||||
use core_test_support::responses::ev_response_created;
|
||||
use core_test_support::responses::mount_sse_once;
|
||||
use core_test_support::responses::mount_sse_sequence;
|
||||
use core_test_support::responses::sse;
|
||||
use core_test_support::responses::start_mock_server;
|
||||
use core_test_support::skip_if_no_network;
|
||||
@@ -22,41 +21,48 @@ use core_test_support::stdio_server_bin;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event;
|
||||
use core_test_support::wait_for_event_with_timeout;
|
||||
use dunce::canonicalize as normalize_path;
|
||||
use tempfile::TempDir;
|
||||
use wiremock::MockServer;
|
||||
|
||||
fn write_plugin_skill_plugin(home: &TempDir) -> std::path::PathBuf {
|
||||
let plugin_root = home.path().join("plugins/cache/test/sample/local");
|
||||
let skill_dir = plugin_root.join("skills/sample-search");
|
||||
std::fs::create_dir_all(skill_dir.as_path()).expect("create plugin skill dir");
|
||||
const SAMPLE_PLUGIN_CONFIG_NAME: &str = "sample@test";
|
||||
const SAMPLE_PLUGIN_DISPLAY_NAME: &str = "sample";
|
||||
|
||||
fn sample_plugin_root(home: &TempDir) -> std::path::PathBuf {
|
||||
home.path().join("plugins/cache/test/sample/local")
|
||||
}
|
||||
|
||||
fn write_sample_plugin_manifest_and_config(home: &TempDir) -> std::path::PathBuf {
|
||||
let plugin_root = sample_plugin_root(home);
|
||||
std::fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir");
|
||||
std::fs::write(
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
r#"{"name":"sample"}"#,
|
||||
format!(r#"{{"name":"{SAMPLE_PLUGIN_DISPLAY_NAME}"}}"#),
|
||||
)
|
||||
.expect("write plugin manifest");
|
||||
std::fs::write(
|
||||
home.path().join("config.toml"),
|
||||
format!(
|
||||
"[features]\nplugins = true\n\n[plugins.\"{SAMPLE_PLUGIN_CONFIG_NAME}\"]\nenabled = true\n"
|
||||
),
|
||||
)
|
||||
.expect("write config");
|
||||
plugin_root
|
||||
}
|
||||
|
||||
fn write_plugin_skill_plugin(home: &TempDir) -> std::path::PathBuf {
|
||||
let plugin_root = write_sample_plugin_manifest_and_config(home);
|
||||
let skill_dir = plugin_root.join("skills/sample-search");
|
||||
std::fs::create_dir_all(skill_dir.as_path()).expect("create plugin skill dir");
|
||||
std::fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
"---\ndescription: inspect sample data\n---\n\n# body\n",
|
||||
)
|
||||
.expect("write plugin skill");
|
||||
std::fs::write(
|
||||
home.path().join("config.toml"),
|
||||
"[features]\nplugins = true\n\n[plugins.\"sample@test\"]\nenabled = true\n",
|
||||
)
|
||||
.expect("write config");
|
||||
skill_dir.join("SKILL.md")
|
||||
}
|
||||
|
||||
fn write_plugin_mcp_plugin(home: &TempDir, command: &str) {
|
||||
let plugin_root = home.path().join("plugins/cache/test/sample/local");
|
||||
std::fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir");
|
||||
std::fs::write(
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
r#"{"name":"sample"}"#,
|
||||
)
|
||||
.expect("write plugin manifest");
|
||||
let plugin_root = write_sample_plugin_manifest_and_config(home);
|
||||
std::fs::write(
|
||||
plugin_root.join(".mcp.json"),
|
||||
format!(
|
||||
@@ -70,21 +76,10 @@ fn write_plugin_mcp_plugin(home: &TempDir, command: &str) {
|
||||
),
|
||||
)
|
||||
.expect("write plugin mcp config");
|
||||
std::fs::write(
|
||||
home.path().join("config.toml"),
|
||||
"[features]\nplugins = true\n\n[plugins.\"sample@test\"]\nenabled = true\n",
|
||||
)
|
||||
.expect("write config");
|
||||
}
|
||||
|
||||
fn write_plugin_app_plugin(home: &TempDir) {
|
||||
let plugin_root = home.path().join("plugins/sample");
|
||||
std::fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir");
|
||||
std::fs::write(
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
r#"{"name":"sample"}"#,
|
||||
)
|
||||
.expect("write plugin manifest");
|
||||
let plugin_root = write_sample_plugin_manifest_and_config(home);
|
||||
std::fs::write(
|
||||
plugin_root.join(".app.json"),
|
||||
r#"{
|
||||
@@ -96,14 +91,6 @@ fn write_plugin_app_plugin(home: &TempDir) {
|
||||
}"#,
|
||||
)
|
||||
.expect("write plugin app config");
|
||||
std::fs::write(
|
||||
home.path().join("config.toml"),
|
||||
format!(
|
||||
"[features]\nplugins = true\n\n[plugins.sample]\nenabled = true\npath = \"{}\"\n",
|
||||
plugin_root.display()
|
||||
),
|
||||
)
|
||||
.expect("write config");
|
||||
}
|
||||
|
||||
async fn build_plugin_test_codex(
|
||||
@@ -120,6 +107,32 @@ async fn build_plugin_test_codex(
|
||||
.codex)
|
||||
}
|
||||
|
||||
async fn build_apps_enabled_plugin_test_codex(
|
||||
server: &MockServer,
|
||||
codex_home: Arc<TempDir>,
|
||||
chatgpt_base_url: String,
|
||||
) -> Result<Arc<codex_core::CodexThread>> {
|
||||
let mut builder = test_codex()
|
||||
.with_home(codex_home)
|
||||
.with_auth(CodexAuth::from_api_key("Test API Key"))
|
||||
.with_config(move |config| {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::Apps)
|
||||
.expect("test config should allow feature update");
|
||||
config
|
||||
.features
|
||||
.disable(Feature::AppsMcpGateway)
|
||||
.expect("test config should allow feature update");
|
||||
config.chatgpt_base_url = chatgpt_base_url;
|
||||
});
|
||||
Ok(builder
|
||||
.build(server)
|
||||
.await
|
||||
.expect("create new conversation")
|
||||
.codex)
|
||||
}
|
||||
|
||||
fn tool_names(body: &serde_json::Value) -> Vec<String> {
|
||||
body.get("tools")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
@@ -137,6 +150,22 @@ fn tool_names(body: &serde_json::Value) -> Vec<String> {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn tool_description(body: &serde_json::Value, tool_name: &str) -> Option<String> {
|
||||
body.get("tools")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.and_then(|tools| {
|
||||
tools.iter().find_map(|tool| {
|
||||
if tool.get("name").and_then(serde_json::Value::as_str) == Some(tool_name) {
|
||||
tool.get("description")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::to_string)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn plugin_skills_append_to_instructions() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
@@ -149,7 +178,7 @@ async fn plugin_skills_append_to_instructions() -> Result<()> {
|
||||
.await;
|
||||
|
||||
let codex_home = Arc::new(TempDir::new()?);
|
||||
let skill_path = write_plugin_skill_plugin(codex_home.as_ref());
|
||||
write_plugin_skill_plugin(codex_home.as_ref());
|
||||
let codex = build_plugin_test_codex(&server, Arc::clone(&codex_home)).await?;
|
||||
|
||||
codex
|
||||
@@ -174,76 +203,48 @@ async fn plugin_skills_append_to_instructions() -> Result<()> {
|
||||
"expected plugins section present"
|
||||
);
|
||||
assert!(
|
||||
instructions_text.contains("### Available plugins\n- `sample`"),
|
||||
"expected enabled plugin list in instructions"
|
||||
);
|
||||
assert!(
|
||||
instructions_text.contains("### How to use plugins"),
|
||||
"expected plugin usage guidance heading"
|
||||
);
|
||||
assert!(
|
||||
instructions_text.contains("## Skills"),
|
||||
"expected skills section present"
|
||||
instructions_text.contains("`sample`"),
|
||||
"expected enabled plugin name in instructions"
|
||||
);
|
||||
assert!(
|
||||
instructions_text.contains("sample:sample-search: inspect sample data"),
|
||||
"expected namespaced plugin skill summary"
|
||||
);
|
||||
let expected_path = normalize_path(skill_path)?;
|
||||
let expected_path_str = expected_path.to_string_lossy().replace('\\', "/");
|
||||
assert!(
|
||||
instructions_text.contains(&expected_path_str),
|
||||
"expected path {expected_path_str} in instructions"
|
||||
);
|
||||
assert!(
|
||||
instructions_text.find("## Plugins") < instructions_text.find("## Skills"),
|
||||
"expected plugins section before skills section"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn plugin_apps_expose_tools_after_canonical_name_mention() -> Result<()> {
|
||||
async fn explicit_plugin_mentions_inject_plugin_guidance() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
let server = start_mock_server().await;
|
||||
let apps_server = AppsTestServer::mount_with_connector_name(&server, "Google Calendar").await?;
|
||||
let mock = mount_sse_sequence(
|
||||
let mock = mount_sse_once(
|
||||
&server,
|
||||
vec![
|
||||
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
|
||||
sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]),
|
||||
],
|
||||
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let codex_home = Arc::new(TempDir::new()?);
|
||||
let rmcp_test_server_bin = match stdio_server_bin() {
|
||||
Ok(bin) => bin,
|
||||
Err(err) => {
|
||||
eprintln!("test_stdio_server binary not available, skipping test: {err}");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
write_plugin_skill_plugin(codex_home.as_ref());
|
||||
write_plugin_mcp_plugin(codex_home.as_ref(), &rmcp_test_server_bin);
|
||||
write_plugin_app_plugin(codex_home.as_ref());
|
||||
#[allow(clippy::expect_used)]
|
||||
let mut builder = test_codex()
|
||||
.with_home(codex_home)
|
||||
.with_auth(CodexAuth::from_api_key("Test API Key"))
|
||||
.with_config(move |config| {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::Apps)
|
||||
.expect("test config should allow feature update");
|
||||
config
|
||||
.features
|
||||
.disable(Feature::AppsMcpGateway)
|
||||
.expect("test config should allow feature update");
|
||||
config.chatgpt_base_url = apps_server.chatgpt_base_url;
|
||||
});
|
||||
let codex = builder
|
||||
.build(&server)
|
||||
.await
|
||||
.expect("create new conversation")
|
||||
.codex;
|
||||
|
||||
let codex =
|
||||
build_apps_enabled_plugin_test_codex(&server, codex_home, apps_server.chatgpt_base_url)
|
||||
.await?;
|
||||
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![codex_protocol::user_input::UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text: "Use @sample for this task.".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
@@ -251,40 +252,46 @@ async fn plugin_apps_expose_tools_after_canonical_name_mention() -> Result<()> {
|
||||
.await?;
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![codex_protocol::user_input::UserInput::Text {
|
||||
text: "Use $google-calendar and then call tools.".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let requests = mock.requests();
|
||||
assert_eq!(requests.len(), 2, "expected two model requests");
|
||||
|
||||
let first_tools = tool_names(&requests[0].body_json());
|
||||
let request = mock.single_request();
|
||||
let developer_messages = request.message_input_texts("developer");
|
||||
assert!(
|
||||
!first_tools
|
||||
developer_messages
|
||||
.iter()
|
||||
.any(|name| name == "mcp__codex_apps__calendar_create_event"),
|
||||
"app tools should stay hidden before plugin app mention: {first_tools:?}"
|
||||
);
|
||||
|
||||
let second_tools = tool_names(&requests[1].body_json());
|
||||
assert!(
|
||||
second_tools
|
||||
.iter()
|
||||
.any(|name| name == "mcp__codex_apps__calendar_create_event"),
|
||||
"calendar create tool should be available after plugin app mention: {second_tools:?}"
|
||||
.any(|text| text.contains("Skills from this plugin")),
|
||||
"expected plugin skills guidance: {developer_messages:?}"
|
||||
);
|
||||
assert!(
|
||||
second_tools
|
||||
developer_messages
|
||||
.iter()
|
||||
.any(|name| name == "mcp__codex_apps__calendar_list_events"),
|
||||
"calendar list tool should be available after plugin app mention: {second_tools:?}"
|
||||
.any(|text| text.contains("MCP servers from this plugin")),
|
||||
"expected visible plugin MCP guidance: {developer_messages:?}"
|
||||
);
|
||||
assert!(
|
||||
developer_messages
|
||||
.iter()
|
||||
.any(|text| text.contains("Apps from this plugin")),
|
||||
"expected visible plugin app guidance: {developer_messages:?}"
|
||||
);
|
||||
let request_body = request.body_json();
|
||||
let request_tools = tool_names(&request_body);
|
||||
assert!(
|
||||
request_tools
|
||||
.iter()
|
||||
.any(|name| name == "mcp__codex_apps__calendar_create_event"),
|
||||
"expected plugin app tools to become visible for this turn: {request_tools:?}"
|
||||
);
|
||||
let echo_description = tool_description(&request_body, "mcp__sample__echo")
|
||||
.expect("plugin MCP tool description should be present");
|
||||
assert!(
|
||||
echo_description.contains("This tool is part of plugin `sample`."),
|
||||
"expected plugin MCP provenance in tool description: {echo_description:?}"
|
||||
);
|
||||
let calendar_description =
|
||||
tool_description(&request_body, "mcp__codex_apps__calendar_create_event")
|
||||
.expect("plugin app tool description should be present");
|
||||
assert!(
|
||||
calendar_description.contains("This tool is part of plugin `sample`."),
|
||||
"expected plugin app provenance in tool description: {calendar_description:?}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use chrono::Utc;
|
||||
use codex_core::CodexAuth;
|
||||
use codex_core::auth::OPENAI_API_KEY_ENV_VAR;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::CodexErrorInfo;
|
||||
use codex_protocol::protocol::ConversationAudioParams;
|
||||
use codex_protocol::protocol::ConversationStartParams;
|
||||
@@ -11,6 +14,7 @@ use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::RealtimeAudioFrame;
|
||||
use codex_protocol::protocol::RealtimeConversationRealtimeEvent;
|
||||
use codex_protocol::protocol::RealtimeEvent;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use core_test_support::responses;
|
||||
use core_test_support::responses::start_mock_server;
|
||||
@@ -18,6 +22,7 @@ use core_test_support::responses::start_websocket_server;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::streaming_sse::StreamingSseChunk;
|
||||
use core_test_support::streaming_sse::start_streaming_sse_server;
|
||||
use core_test_support::test_codex::TestCodex;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event;
|
||||
use core_test_support::wait_for_event_match;
|
||||
@@ -25,9 +30,57 @@ use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use std::ffi::OsString;
|
||||
use std::fs;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
const STARTUP_CONTEXT_HEADER: &str = "Startup context from Codex.";
|
||||
const MEMORY_PROMPT_PHRASE: &str =
|
||||
"You have access to a memory folder with guidance from prior runs.";
|
||||
|
||||
fn websocket_request_text(
|
||||
request: &core_test_support::responses::WebSocketRequest,
|
||||
) -> Option<String> {
|
||||
request.body_json()["item"]["content"][0]["text"]
|
||||
.as_str()
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
fn websocket_request_instructions(
|
||||
request: &core_test_support::responses::WebSocketRequest,
|
||||
) -> Option<String> {
|
||||
request.body_json()["session"]["instructions"]
|
||||
.as_str()
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
async fn seed_recent_thread(
|
||||
test: &TestCodex,
|
||||
title: &str,
|
||||
first_user_message: &str,
|
||||
slug: &str,
|
||||
) -> Result<()> {
|
||||
let db = test.codex.state_db().context("state db enabled")?;
|
||||
let thread_id = ThreadId::new();
|
||||
let updated_at = Utc::now();
|
||||
let mut metadata_builder = codex_state::ThreadMetadataBuilder::new(
|
||||
thread_id,
|
||||
test.codex_home_path()
|
||||
.join(format!("rollout-{thread_id}.jsonl")),
|
||||
updated_at,
|
||||
SessionSource::Cli,
|
||||
);
|
||||
metadata_builder.cwd = test.workspace_path(format!("workspace-{slug}"));
|
||||
metadata_builder.model_provider = Some("test-provider".to_string());
|
||||
metadata_builder.git_branch = Some(format!("branch-{slug}"));
|
||||
let mut metadata = metadata_builder.build("test-provider");
|
||||
metadata.title = title.to_string();
|
||||
metadata.first_user_message = Some(first_user_message.to_string());
|
||||
db.upsert_thread(&metadata).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn conversation_start_audio_text_close_round_trip() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
@@ -122,10 +175,9 @@ async fn conversation_start_audio_text_close_round_trip() -> Result<()> {
|
||||
connection[0].body_json()["type"].as_str(),
|
||||
Some("session.update")
|
||||
);
|
||||
assert_eq!(
|
||||
connection[0].body_json()["session"]["instructions"].as_str(),
|
||||
Some("backend prompt")
|
||||
);
|
||||
let initial_instructions = websocket_request_instructions(&connection[0])
|
||||
.expect("initial session update instructions");
|
||||
assert!(initial_instructions.starts_with("backend prompt"));
|
||||
assert_eq!(
|
||||
server.handshakes()[1]
|
||||
.header("x-session-id")
|
||||
@@ -452,19 +504,17 @@ async fn conversation_second_start_replaces_runtime() -> Result<()> {
|
||||
let connections = server.connections();
|
||||
assert_eq!(connections.len(), 3);
|
||||
assert_eq!(connections[1].len(), 1);
|
||||
assert_eq!(
|
||||
connections[1][0].body_json()["session"]["instructions"].as_str(),
|
||||
Some("old")
|
||||
);
|
||||
let old_instructions =
|
||||
websocket_request_instructions(&connections[1][0]).expect("old session instructions");
|
||||
assert!(old_instructions.starts_with("old"));
|
||||
assert_eq!(
|
||||
server.handshakes()[1].header("x-session-id").as_deref(),
|
||||
Some("conv_old")
|
||||
);
|
||||
assert_eq!(connections[2].len(), 2);
|
||||
assert_eq!(
|
||||
connections[2][0].body_json()["session"]["instructions"].as_str(),
|
||||
Some("new")
|
||||
);
|
||||
let new_instructions =
|
||||
websocket_request_instructions(&connections[2][0]).expect("new session instructions");
|
||||
assert!(new_instructions.starts_with("new"));
|
||||
assert_eq!(
|
||||
server.handshakes()[2].header("x-session-id").as_deref(),
|
||||
Some("conv_new")
|
||||
@@ -570,9 +620,178 @@ async fn conversation_uses_experimental_realtime_ws_backend_prompt_override() ->
|
||||
|
||||
let connections = server.connections();
|
||||
assert_eq!(connections.len(), 2);
|
||||
let overridden_instructions = websocket_request_instructions(&connections[1][0])
|
||||
.expect("overridden session instructions");
|
||||
assert!(overridden_instructions.starts_with("prompt from config"));
|
||||
|
||||
server.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn conversation_start_injects_startup_context_from_thread_history() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_websocket_server(vec![
|
||||
vec![],
|
||||
vec![vec![json!({
|
||||
"type": "session.updated",
|
||||
"session": { "id": "sess_context", "instructions": "backend prompt" }
|
||||
})]],
|
||||
])
|
||||
.await;
|
||||
|
||||
let mut builder = test_codex();
|
||||
let test = builder.build_with_websocket_server(&server).await?;
|
||||
seed_recent_thread(
|
||||
&test,
|
||||
"Recent work: cleaned up startup flows and reviewed websocket routing.",
|
||||
"Investigate realtime startup context",
|
||||
"latest",
|
||||
)
|
||||
.await?;
|
||||
fs::create_dir_all(test.workspace_path("docs"))?;
|
||||
fs::write(test.workspace_path("README.md"), "workspace marker")?;
|
||||
|
||||
test.codex
|
||||
.submit(Op::RealtimeConversationStart(ConversationStartParams {
|
||||
prompt: "backend prompt".to_string(),
|
||||
session_id: None,
|
||||
}))
|
||||
.await?;
|
||||
|
||||
wait_for_event_match(&test.codex, |msg| match msg {
|
||||
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
|
||||
payload: RealtimeEvent::SessionUpdated { session_id, .. },
|
||||
}) if session_id == "sess_context" => Some(Ok(())),
|
||||
EventMsg::Error(err) => Some(Err(err.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|err: ErrorEvent| panic!("conversation start failed: {err:?}"));
|
||||
|
||||
let startup_context_request = server.wait_for_request(1, 0).await;
|
||||
let startup_context = websocket_request_instructions(&startup_context_request)
|
||||
.expect("startup context request should contain instructions");
|
||||
|
||||
assert!(startup_context.contains(STARTUP_CONTEXT_HEADER));
|
||||
assert!(!startup_context.contains("## User"));
|
||||
assert!(startup_context.contains("### "));
|
||||
assert!(startup_context.contains("Recent sessions: 1"));
|
||||
assert!(startup_context.contains("Latest branch: branch-latest"));
|
||||
assert!(startup_context.contains("User asks:"));
|
||||
assert!(startup_context.contains("Investigate realtime startup context"));
|
||||
assert!(startup_context.contains("## Machine / Workspace Map"));
|
||||
assert!(startup_context.contains("README.md"));
|
||||
assert!(!startup_context.contains(MEMORY_PROMPT_PHRASE));
|
||||
|
||||
server.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn conversation_startup_context_falls_back_to_workspace_map() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_websocket_server(vec![
|
||||
vec![],
|
||||
vec![vec![json!({
|
||||
"type": "session.updated",
|
||||
"session": { "id": "sess_workspace", "instructions": "backend prompt" }
|
||||
})]],
|
||||
])
|
||||
.await;
|
||||
|
||||
let mut builder = test_codex();
|
||||
let test = builder.build_with_websocket_server(&server).await?;
|
||||
fs::create_dir_all(test.workspace_path("codex-rs/core"))?;
|
||||
fs::write(test.workspace_path("notes.txt"), "workspace marker")?;
|
||||
|
||||
test.codex
|
||||
.submit(Op::RealtimeConversationStart(ConversationStartParams {
|
||||
prompt: "backend prompt".to_string(),
|
||||
session_id: None,
|
||||
}))
|
||||
.await?;
|
||||
|
||||
wait_for_event_match(&test.codex, |msg| match msg {
|
||||
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
|
||||
payload: RealtimeEvent::SessionUpdated { session_id, .. },
|
||||
}) if session_id == "sess_workspace" => Some(Ok(())),
|
||||
EventMsg::Error(err) => Some(Err(err.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|err: ErrorEvent| panic!("conversation start failed: {err:?}"));
|
||||
|
||||
let startup_context_request = server.wait_for_request(1, 0).await;
|
||||
let startup_context = websocket_request_instructions(&startup_context_request)
|
||||
.expect("startup context request should contain instructions");
|
||||
|
||||
assert!(startup_context.contains(STARTUP_CONTEXT_HEADER));
|
||||
assert!(startup_context.contains("## Machine / Workspace Map"));
|
||||
assert!(startup_context.contains("notes.txt"));
|
||||
assert!(startup_context.contains("codex-rs/"));
|
||||
|
||||
server.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn conversation_startup_context_is_truncated_and_sent_once_per_start() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_websocket_server(vec![
|
||||
vec![],
|
||||
vec![
|
||||
vec![json!({
|
||||
"type": "session.updated",
|
||||
"session": { "id": "sess_truncated", "instructions": "backend prompt" }
|
||||
})],
|
||||
vec![],
|
||||
],
|
||||
])
|
||||
.await;
|
||||
|
||||
let oversized_summary = "recent work ".repeat(3_500);
|
||||
let mut builder = test_codex();
|
||||
let test = builder.build_with_websocket_server(&server).await?;
|
||||
seed_recent_thread(&test, &oversized_summary, "summary", "oversized").await?;
|
||||
fs::write(test.workspace_path("marker.txt"), "marker")?;
|
||||
|
||||
test.codex
|
||||
.submit(Op::RealtimeConversationStart(ConversationStartParams {
|
||||
prompt: "backend prompt".to_string(),
|
||||
session_id: None,
|
||||
}))
|
||||
.await?;
|
||||
|
||||
wait_for_event_match(&test.codex, |msg| match msg {
|
||||
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
|
||||
payload: RealtimeEvent::SessionUpdated { session_id, .. },
|
||||
}) if session_id == "sess_truncated" => Some(Ok(())),
|
||||
EventMsg::Error(err) => Some(Err(err.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|err: ErrorEvent| panic!("conversation start failed: {err:?}"));
|
||||
|
||||
let startup_context_request = server.wait_for_request(1, 0).await;
|
||||
let startup_context = websocket_request_instructions(&startup_context_request)
|
||||
.expect("startup context request should contain instructions");
|
||||
assert!(startup_context.contains(STARTUP_CONTEXT_HEADER));
|
||||
assert!(startup_context.len() <= 20_500);
|
||||
|
||||
test.codex
|
||||
.submit(Op::RealtimeConversationText(ConversationTextParams {
|
||||
text: "hello".to_string(),
|
||||
}))
|
||||
.await?;
|
||||
|
||||
let explicit_text_request = server.wait_for_request(1, 1).await;
|
||||
assert_eq!(
|
||||
connections[1][0].body_json()["session"]["instructions"].as_str(),
|
||||
Some("prompt from config")
|
||||
websocket_request_text(&explicit_text_request),
|
||||
Some("hello".to_string())
|
||||
);
|
||||
|
||||
server.shutdown().await;
|
||||
|
||||
@@ -27,6 +27,7 @@ fn resume_history(
|
||||
let turn_id = "resume-warning-seed-turn".to_string();
|
||||
let turn_ctx = TurnContextItem {
|
||||
turn_id: Some(turn_id.clone()),
|
||||
trace_id: None,
|
||||
cwd: config.cwd.clone(),
|
||||
current_date: None,
|
||||
timezone: None,
|
||||
|
||||
@@ -361,6 +361,110 @@ async fn explicit_app_mentions_expose_apps_tools_without_search() -> Result<()>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn search_tool_results_match_plugin_names_and_annotate_descriptions() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let apps_server = AppsTestServer::mount_with_connector_name(&server, "Google Calendar").await?;
|
||||
let call_id = "tool-search";
|
||||
let args = json!({
|
||||
"query": "sample",
|
||||
"limit": 2,
|
||||
});
|
||||
let mock = mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_function_call(
|
||||
call_id,
|
||||
SEARCH_TOOL_BM25_TOOL_NAME,
|
||||
&serde_json::to_string(&args)?,
|
||||
),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
let codex_home = Arc::new(tempfile::TempDir::new()?);
|
||||
let plugin_root = codex_home.path().join("plugins/cache/test/sample/local");
|
||||
std::fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir");
|
||||
std::fs::write(
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
r#"{"name":"sample"}"#,
|
||||
)
|
||||
.expect("write plugin manifest");
|
||||
std::fs::write(
|
||||
plugin_root.join(".app.json"),
|
||||
r#"{
|
||||
"apps": {
|
||||
"calendar": {
|
||||
"id": "calendar"
|
||||
}
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.expect("write plugin app config");
|
||||
std::fs::write(
|
||||
codex_home.path().join("config.toml"),
|
||||
"[features]\nplugins = true\n\n[plugins.\"sample@test\"]\nenabled = true\n",
|
||||
)
|
||||
.expect("write config");
|
||||
|
||||
let mut builder =
|
||||
configured_builder(apps_server.chatgpt_base_url.clone(), None).with_home(codex_home);
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
test.submit_turn_with_policies(
|
||||
"find sample plugin tools",
|
||||
AskForApproval::Never,
|
||||
SandboxPolicy::DangerFullAccess,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let requests = mock.requests();
|
||||
assert_eq!(
|
||||
requests.len(),
|
||||
2,
|
||||
"expected 2 requests, got {}",
|
||||
requests.len()
|
||||
);
|
||||
|
||||
let search_output_payload = search_tool_output_payload(&requests[1], call_id);
|
||||
let result_tools = search_result_tools(&search_output_payload);
|
||||
assert_eq!(result_tools.len(), 2, "expected 2 search results");
|
||||
assert!(
|
||||
result_tools.iter().all(|tool| {
|
||||
tool.get("description")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|description| {
|
||||
description.contains("This tool is part of plugin `sample`.")
|
||||
})
|
||||
}),
|
||||
"expected plugin provenance in search result descriptions: {search_output_payload:?}"
|
||||
);
|
||||
assert!(
|
||||
result_tools
|
||||
.iter()
|
||||
.any(|tool| { tool.get("name").and_then(Value::as_str) == Some(CALENDAR_CREATE_TOOL) }),
|
||||
"expected calendar create tool in search results: {search_output_payload:?}"
|
||||
);
|
||||
assert!(
|
||||
result_tools
|
||||
.iter()
|
||||
.any(|tool| { tool.get("name").and_then(Value::as_str) == Some(CALENDAR_LIST_TOOL) }),
|
||||
"expected calendar list tool in search results: {search_output_payload:?}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn search_tool_selection_persists_across_turns() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
---
|
||||
source: core/tests/suite/compact_resume_fork.rs
|
||||
expression: "context_snapshot::format_labeled_requests_snapshot(\"rollback past compaction replay after rollback\",\n&[(\"compaction request\", &requests[1]), (\"before rollback\", &requests[2]),\n(\"after rollback\", &requests[3]),],\n&ContextSnapshotOptions::default().render_mode(ContextSnapshotRenderMode::KindWithTextPrefix\n{ max_chars: 64 }),)"
|
||||
---
|
||||
Scenario: rollback past compaction replay after rollback
|
||||
|
||||
## compaction request
|
||||
00:message/developer:<PERMISSIONS_INSTRUCTIONS>
|
||||
01:message/user[2]:
|
||||
[01] <AGENTS_MD>
|
||||
[02] <ENVIRONMENT_CONTEXT:cwd=<CWD>>
|
||||
02:message/user:hello world
|
||||
03:message/assistant:FIRST_REPLY
|
||||
04:message/user:<SUMMARIZATION_PROMPT>
|
||||
|
||||
## before rollback
|
||||
00:message/user:hello world
|
||||
01:message/user:<COMPACTION_SUMMARY>\nSUMMARY_ONLY_CONTEXT
|
||||
02:message/developer:<PERMISSIONS_INSTRUCTIONS>
|
||||
03:message/user[2]:
|
||||
[01] <AGENTS_MD>
|
||||
[02] <ENVIRONMENT_CONTEXT:cwd=<CWD>>
|
||||
04:message/user:EDITED_AFTER_COMPACT
|
||||
|
||||
## after rollback
|
||||
00:message/user:hello world
|
||||
01:message/user:<COMPACTION_SUMMARY>\nSUMMARY_ONLY_CONTEXT
|
||||
02:message/developer:<PERMISSIONS_INSTRUCTIONS>
|
||||
03:message/user[2]:
|
||||
[01] <AGENTS_MD>
|
||||
[02] <ENVIRONMENT_CONTEXT:cwd=<CWD>>
|
||||
04:message/developer:<PERMISSIONS_INSTRUCTIONS>
|
||||
05:message/user[2]:
|
||||
[01] <AGENTS_MD>
|
||||
[02] <ENVIRONMENT_CONTEXT:cwd=<CWD>>
|
||||
06:message/user:AFTER_ROLLBACK
|
||||
@@ -10,7 +10,6 @@ codex-protocol = { workspace = true }
|
||||
sentry = { version = "0.46" }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
url = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use url::Url;
|
||||
|
||||
const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
|
||||
const OPENAI_BASE_URL_ENV_VAR: &str = "OPENAI_BASE_URL";
|
||||
pub const FEEDBACK_DIAGNOSTICS_ATTACHMENT_FILENAME: &str = "codex-connectivity-diagnostics.txt";
|
||||
const PROXY_ENV_VARS: &[&str] = &[
|
||||
@@ -49,16 +46,8 @@ impl FeedbackDiagnostics {
|
||||
let proxy_details = PROXY_ENV_VARS
|
||||
.iter()
|
||||
.filter_map(|key| {
|
||||
let value = env.get(*key)?.trim();
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let detail = match sanitize_proxy_value(value) {
|
||||
Some(sanitized) => format!("{key} = {sanitized}"),
|
||||
None => format!("{key} = invalid value"),
|
||||
};
|
||||
Some(detail)
|
||||
let value = env.get(*key)?;
|
||||
Some(format!("{key} = {value}"))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if !proxy_details.is_empty() {
|
||||
@@ -70,17 +59,10 @@ impl FeedbackDiagnostics {
|
||||
}
|
||||
|
||||
if let Some(value) = env.get(OPENAI_BASE_URL_ENV_VAR).map(String::as_str) {
|
||||
let trimmed = value.trim();
|
||||
if !trimmed.is_empty() && trimmed.trim_end_matches('/') != DEFAULT_OPENAI_BASE_URL {
|
||||
let detail = match sanitize_url_for_display(trimmed) {
|
||||
Some(sanitized) => format!("{OPENAI_BASE_URL_ENV_VAR} = {sanitized}"),
|
||||
None => format!("{OPENAI_BASE_URL_ENV_VAR} = invalid value"),
|
||||
};
|
||||
diagnostics.push(FeedbackDiagnostic {
|
||||
headline: "OPENAI_BASE_URL is set and may affect connectivity.".to_string(),
|
||||
details: vec![detail],
|
||||
});
|
||||
}
|
||||
diagnostics.push(FeedbackDiagnostic {
|
||||
headline: "OPENAI_BASE_URL is set and may affect connectivity.".to_string(),
|
||||
details: vec![format!("{OPENAI_BASE_URL_ENV_VAR} = {value}")],
|
||||
});
|
||||
}
|
||||
|
||||
Self { diagnostics }
|
||||
@@ -114,40 +96,15 @@ impl FeedbackDiagnostics {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sanitize_url_for_display(raw: &str) -> Option<String> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let Ok(mut url) = Url::parse(trimmed) else {
|
||||
return None;
|
||||
};
|
||||
let _ = url.set_username("");
|
||||
let _ = url.set_password(None);
|
||||
url.set_query(None);
|
||||
url.set_fragment(None);
|
||||
Some(url.to_string().trim_end_matches('/').to_string()).filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn sanitize_proxy_value(raw: &str) -> Option<String> {
|
||||
if raw.contains("://") {
|
||||
return sanitize_url_for_display(raw);
|
||||
}
|
||||
|
||||
sanitize_url_for_display(&format!("http://{raw}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::FeedbackDiagnostic;
|
||||
use super::FeedbackDiagnostics;
|
||||
use super::sanitize_url_for_display;
|
||||
|
||||
#[test]
|
||||
fn collect_from_pairs_reports_sanitized_diagnostics_and_attachment() {
|
||||
fn collect_from_pairs_reports_raw_values_and_attachment() {
|
||||
let diagnostics = FeedbackDiagnostics::collect_from_pairs([
|
||||
(
|
||||
"HTTPS_PROXY",
|
||||
@@ -167,14 +124,16 @@ mod tests {
|
||||
"Proxy environment variables are set and may affect connectivity."
|
||||
.to_string(),
|
||||
details: vec![
|
||||
"http_proxy = http://proxy.example.com:8080".to_string(),
|
||||
"HTTPS_PROXY = https://secure-proxy.example.com".to_string(),
|
||||
"http_proxy = proxy.example.com:8080".to_string(),
|
||||
"HTTPS_PROXY = https://user:password@secure-proxy.example.com:443?secret=1".to_string(),
|
||||
"all_proxy = socks5h://all-proxy.example.com:1080".to_string(),
|
||||
],
|
||||
},
|
||||
FeedbackDiagnostic {
|
||||
headline: "OPENAI_BASE_URL is set and may affect connectivity.".to_string(),
|
||||
details: vec!["OPENAI_BASE_URL = https://example.com/v1".to_string()],
|
||||
details: vec![
|
||||
"OPENAI_BASE_URL = https://example.com/v1?token=secret".to_string(),
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -183,33 +142,42 @@ mod tests {
|
||||
assert_eq!(
|
||||
diagnostics.attachment_text(),
|
||||
Some(
|
||||
"Connectivity diagnostics\n\n- Proxy environment variables are set and may affect connectivity.\n - http_proxy = http://proxy.example.com:8080\n - HTTPS_PROXY = https://secure-proxy.example.com\n - all_proxy = socks5h://all-proxy.example.com:1080\n- OPENAI_BASE_URL is set and may affect connectivity.\n - OPENAI_BASE_URL = https://example.com/v1"
|
||||
"Connectivity diagnostics\n\n- Proxy environment variables are set and may affect connectivity.\n - http_proxy = proxy.example.com:8080\n - HTTPS_PROXY = https://user:password@secure-proxy.example.com:443?secret=1\n - all_proxy = socks5h://all-proxy.example.com:1080\n- OPENAI_BASE_URL is set and may affect connectivity.\n - OPENAI_BASE_URL = https://example.com/v1?token=secret"
|
||||
.to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_from_pairs_ignores_absent_and_default_values() {
|
||||
for diagnostics in [
|
||||
FeedbackDiagnostics::collect_from_pairs(Vec::<(String, String)>::new()),
|
||||
FeedbackDiagnostics::collect_from_pairs([(
|
||||
"OPENAI_BASE_URL",
|
||||
"https://api.openai.com/v1/",
|
||||
)]),
|
||||
] {
|
||||
assert_eq!(diagnostics, FeedbackDiagnostics::default());
|
||||
assert_eq!(diagnostics.attachment_text(), None);
|
||||
}
|
||||
fn collect_from_pairs_ignores_absent_values() {
|
||||
let diagnostics = FeedbackDiagnostics::collect_from_pairs(Vec::<(String, String)>::new());
|
||||
assert_eq!(diagnostics, FeedbackDiagnostics::default());
|
||||
assert_eq!(diagnostics.attachment_text(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_from_pairs_reports_invalid_values_without_echoing_them() {
|
||||
let invalid_proxy = "not a valid\nproxy";
|
||||
let invalid_base_url = "not a valid\nurl";
|
||||
fn collect_from_pairs_preserves_openai_base_url_literal_value() {
|
||||
let diagnostics = FeedbackDiagnostics::collect_from_pairs([(
|
||||
"OPENAI_BASE_URL",
|
||||
"https://api.openai.com/v1/",
|
||||
)]);
|
||||
|
||||
assert_eq!(
|
||||
diagnostics,
|
||||
FeedbackDiagnostics {
|
||||
diagnostics: vec![FeedbackDiagnostic {
|
||||
headline: "OPENAI_BASE_URL is set and may affect connectivity.".to_string(),
|
||||
details: vec!["OPENAI_BASE_URL = https://api.openai.com/v1/".to_string()],
|
||||
}],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_from_pairs_preserves_whitespace_and_empty_values() {
|
||||
let diagnostics = FeedbackDiagnostics::collect_from_pairs([
|
||||
("HTTP_PROXY", invalid_proxy),
|
||||
("OPENAI_BASE_URL", invalid_base_url),
|
||||
("HTTP_PROXY", " proxy with spaces "),
|
||||
("OPENAI_BASE_URL", ""),
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
@@ -220,28 +188,42 @@ mod tests {
|
||||
headline:
|
||||
"Proxy environment variables are set and may affect connectivity."
|
||||
.to_string(),
|
||||
details: vec!["HTTP_PROXY = invalid value".to_string()],
|
||||
details: vec!["HTTP_PROXY = proxy with spaces ".to_string()],
|
||||
},
|
||||
FeedbackDiagnostic {
|
||||
headline: "OPENAI_BASE_URL is set and may affect connectivity.".to_string(),
|
||||
details: vec!["OPENAI_BASE_URL = invalid value".to_string()],
|
||||
details: vec!["OPENAI_BASE_URL = ".to_string()],
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
let attachment_text = diagnostics
|
||||
.attachment_text()
|
||||
.expect("invalid diagnostics should still render attachment text");
|
||||
assert!(!attachment_text.contains(invalid_proxy));
|
||||
assert!(!attachment_text.contains(invalid_base_url));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_url_for_display_strips_credentials_query_and_fragment() {
|
||||
let sanitized = sanitize_url_for_display(
|
||||
"https://user:password@example.com:8443/v1?token=secret#fragment",
|
||||
);
|
||||
fn collect_from_pairs_reports_values_verbatim() {
|
||||
let proxy_value = "not a valid proxy";
|
||||
let base_url_value = "hello";
|
||||
let diagnostics = FeedbackDiagnostics::collect_from_pairs([
|
||||
("HTTP_PROXY", proxy_value),
|
||||
("OPENAI_BASE_URL", base_url_value),
|
||||
]);
|
||||
|
||||
assert_eq!(sanitized, Some("https://example.com:8443/v1".to_string()));
|
||||
assert_eq!(
|
||||
diagnostics,
|
||||
FeedbackDiagnostics {
|
||||
diagnostics: vec![
|
||||
FeedbackDiagnostic {
|
||||
headline:
|
||||
"Proxy environment variables are set and may affect connectivity."
|
||||
.to_string(),
|
||||
details: vec!["HTTP_PROXY = not a valid proxy".to_string()],
|
||||
},
|
||||
FeedbackDiagnostic {
|
||||
headline: "OPENAI_BASE_URL is set and may affect connectivity.".to_string(),
|
||||
details: vec!["OPENAI_BASE_URL = hello".to_string()],
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@ into this binary.
|
||||
- When enabled, symlink-in-path and non-existent protected paths inside
|
||||
writable roots are blocked by mounting `/dev/null` on the symlink or first
|
||||
missing component.
|
||||
- When enabled, the helper isolates the PID namespace via `--unshare-pid`.
|
||||
- When enabled, the helper explicitly isolates the user namespace via
|
||||
`--unshare-user` and the PID namespace via `--unshare-pid`.
|
||||
- When enabled and network is restricted without proxy routing, the helper also
|
||||
isolates the network namespace via `--unshare-net`.
|
||||
- In managed proxy mode, the helper uses `--unshare-net` plus an internal
|
||||
|
||||
@@ -107,6 +107,9 @@ fn create_bwrap_flags_full_filesystem(command: Vec<String>, options: BwrapOption
|
||||
"--bind".to_string(),
|
||||
"/".to_string(),
|
||||
"/".to_string(),
|
||||
// Always enter a fresh user namespace so root inside a container does
|
||||
// not need ambient CAP_SYS_ADMIN to create the remaining namespaces.
|
||||
"--unshare-user".to_string(),
|
||||
"--unshare-pid".to_string(),
|
||||
];
|
||||
if options.network_mode.should_unshare_network() {
|
||||
@@ -132,6 +135,9 @@ fn create_bwrap_flags(
|
||||
args.push("--new-session".to_string());
|
||||
args.push("--die-with-parent".to_string());
|
||||
args.extend(create_filesystem_args(sandbox_policy, cwd)?);
|
||||
// Request a user namespace explicitly rather than relying on bubblewrap's
|
||||
// auto-enable behavior, which is skipped when the caller runs as uid 0.
|
||||
args.push("--unshare-user".to_string());
|
||||
// Isolate the PID namespace.
|
||||
args.push("--unshare-pid".to_string());
|
||||
if options.network_mode.should_unshare_network() {
|
||||
@@ -425,6 +431,7 @@ mod tests {
|
||||
"--bind".to_string(),
|
||||
"/".to_string(),
|
||||
"/".to_string(),
|
||||
"--unshare-user".to_string(),
|
||||
"--unshare-pid".to_string(),
|
||||
"--unshare-net".to_string(),
|
||||
"--proc".to_string(),
|
||||
|
||||
@@ -49,6 +49,7 @@ fn inserts_bwrap_argv0_before_command_separator() {
|
||||
"/".to_string(),
|
||||
"--dev".to_string(),
|
||||
"/dev".to_string(),
|
||||
"--unshare-user".to_string(),
|
||||
"--unshare-pid".to_string(),
|
||||
"--proc".to_string(),
|
||||
"/proc".to_string(),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user