Merge branch 'main' into codex/fix-agent-job-fanout-fill

This commit is contained in:
jif-oai
2026-04-21 19:33:39 +01:00
committed by GitHub
184 changed files with 13548 additions and 3133 deletions
+1192 -24
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -24,6 +24,7 @@ members = [
"collaboration-mode-templates",
"connectors",
"config",
"device-key",
"shell-command",
"shell-escalation",
"skills",
@@ -244,6 +245,7 @@ env_logger = "0.11.9"
eventsource-stream = "0.2.3"
futures = { version = "0.3", default-features = false }
gethostname = "1.1.0"
gix = { version = "0.81.0", default-features = false, features = ["sha1"] }
glob = "0.3"
globset = "0.4"
hmac = "0.12.1"
@@ -282,6 +284,7 @@ os_info = "3.12.0"
owo-colors = "4.3.0"
path-absolutize = "3.1.1"
pathdiff = "0.2"
p256 = "0.13.2"
portable-pty = "0.9.0"
predicates = "3"
pretty_assertions = "1.4.1"
@@ -430,6 +433,11 @@ ignored = [
"codex-v8-poc",
]
[profile.dev]
# Keep line tables/backtraces while avoiding expensive full variable debug info
# across local dev builds.
debug = 1
[profile.dev-small]
inherits = "dev"
opt-level = 0
@@ -521,6 +521,200 @@
}
]
},
"DeviceKeyCreateParams": {
"description": "Create a controller-local device key with a random key id.",
"properties": {
"accountUserId": {
"type": "string"
},
"clientId": {
"type": "string"
},
"protectionPolicy": {
"anyOf": [
{
"$ref": "#/definitions/DeviceKeyProtectionPolicy"
},
{
"type": "null"
}
],
"description": "Defaults to `hardware_only` when omitted."
}
},
"required": [
"accountUserId",
"clientId"
],
"type": "object"
},
"DeviceKeyProtectionPolicy": {
"description": "Protection policy for creating or loading a controller-local device key.",
"enum": [
"hardware_only",
"allow_os_protected_nonextractable"
],
"type": "string"
},
"DeviceKeyPublicParams": {
"description": "Fetch a controller-local device key public key by id.",
"properties": {
"keyId": {
"type": "string"
}
},
"required": [
"keyId"
],
"type": "object"
},
"DeviceKeySignParams": {
"description": "Sign an accepted structured payload with a controller-local device key.",
"properties": {
"keyId": {
"type": "string"
},
"payload": {
"$ref": "#/definitions/DeviceKeySignPayload"
}
},
"required": [
"keyId",
"payload"
],
"type": "object"
},
"DeviceKeySignPayload": {
"description": "Structured payloads accepted by `device/key/sign`.",
"oneOf": [
{
"description": "Payload bound to one remote-control controller websocket `/client` connection challenge.",
"properties": {
"accountUserId": {
"type": "string"
},
"audience": {
"$ref": "#/definitions/RemoteControlClientConnectionAudience"
},
"clientId": {
"type": "string"
},
"nonce": {
"type": "string"
},
"scopes": {
"description": "Must contain exactly `remote_control_controller_websocket`.",
"items": {
"type": "string"
},
"type": "array"
},
"sessionId": {
"description": "Backend-issued websocket session id that this proof authorizes.",
"type": "string"
},
"targetOrigin": {
"description": "Origin of the backend endpoint that issued the challenge and will verify this proof.",
"type": "string"
},
"targetPath": {
"description": "Websocket route path that this proof authorizes.",
"type": "string"
},
"tokenExpiresAt": {
"description": "Remote-control token expiration as Unix seconds.",
"format": "int64",
"type": "integer"
},
"tokenSha256Base64url": {
"description": "SHA-256 of the controller-scoped remote-control token, encoded as unpadded base64url.",
"type": "string"
},
"type": {
"enum": [
"remoteControlClientConnection"
],
"title": "RemoteControlClientConnectionDeviceKeySignPayloadType",
"type": "string"
}
},
"required": [
"accountUserId",
"audience",
"clientId",
"nonce",
"scopes",
"sessionId",
"targetOrigin",
"targetPath",
"tokenExpiresAt",
"tokenSha256Base64url",
"type"
],
"title": "RemoteControlClientConnectionDeviceKeySignPayload",
"type": "object"
},
{
"description": "Payload bound to a remote-control client `/client/enroll` ownership challenge.",
"properties": {
"accountUserId": {
"type": "string"
},
"audience": {
"$ref": "#/definitions/RemoteControlClientEnrollmentAudience"
},
"challengeExpiresAt": {
"description": "Enrollment challenge expiration as Unix seconds.",
"format": "int64",
"type": "integer"
},
"challengeId": {
"description": "Backend-issued enrollment challenge id that this proof authorizes.",
"type": "string"
},
"clientId": {
"type": "string"
},
"deviceIdentitySha256Base64url": {
"description": "SHA-256 of the requested device identity operation, encoded as unpadded base64url.",
"type": "string"
},
"nonce": {
"type": "string"
},
"targetOrigin": {
"description": "Origin of the backend endpoint that issued the challenge and will verify this proof.",
"type": "string"
},
"targetPath": {
"description": "HTTP route path that this proof authorizes.",
"type": "string"
},
"type": {
"enum": [
"remoteControlClientEnrollment"
],
"title": "RemoteControlClientEnrollmentDeviceKeySignPayloadType",
"type": "string"
}
},
"required": [
"accountUserId",
"audience",
"challengeExpiresAt",
"challengeId",
"clientId",
"deviceIdentitySha256Base64url",
"nonce",
"targetOrigin",
"targetPath",
"type"
],
"title": "RemoteControlClientEnrollmentDeviceKeySignPayload",
"type": "object"
}
]
},
"DynamicToolSpec": {
"properties": {
"deferLoading": {
@@ -1747,6 +1941,20 @@
}
]
},
"RemoteControlClientConnectionAudience": {
"description": "Audience for a remote-control client connection device-key proof.",
"enum": [
"remote_control_client_websocket"
],
"type": "string"
},
"RemoteControlClientEnrollmentAudience": {
"description": "Audience for a remote-control client enrollment device-key proof.",
"enum": [
"remote_control_client_enrollment"
],
"type": "string"
},
"RequestId": {
"anyOf": [
{
@@ -4360,6 +4568,78 @@
"title": "App/listRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"device/key/create"
],
"title": "Device/key/createRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/DeviceKeyCreateParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Device/key/createRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"device/key/public"
],
"title": "Device/key/publicRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/DeviceKeyPublicParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Device/key/publicRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"device/key/sign"
],
"title": "Device/key/signRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/DeviceKeySignParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Device/key/signRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -295,6 +295,9 @@
}
},
"properties": {
"cwd": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"itemId": {
"type": "string"
},
@@ -315,6 +318,7 @@
}
},
"required": [
"cwd",
"itemId",
"permissions",
"threadId",
@@ -1584,6 +1584,9 @@
},
"PermissionsRequestApprovalParams": {
"properties": {
"cwd": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"itemId": {
"type": "string"
},
@@ -1604,6 +1607,7 @@
}
},
"required": [
"cwd",
"itemId",
"permissions",
"threadId",
@@ -737,6 +737,78 @@
"title": "App/listRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"device/key/create"
],
"title": "Device/key/createRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/DeviceKeyCreateParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Device/key/createRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"device/key/public"
],
"title": "Device/key/publicRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/DeviceKeyPublicParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Device/key/publicRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"device/key/sign"
],
"title": "Device/key/signRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/DeviceKeySignParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Device/key/signRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -3360,6 +3432,9 @@
"PermissionsRequestApprovalParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"cwd": {
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
"itemId": {
"type": "string"
},
@@ -3380,6 +3455,7 @@
}
},
"required": [
"cwd",
"itemId",
"permissions",
"threadId",
@@ -7324,6 +7400,300 @@
"title": "DeprecationNoticeNotification",
"type": "object"
},
"DeviceKeyAlgorithm": {
"description": "Device-key algorithm reported at enrollment and signing boundaries.",
"enum": [
"ecdsa_p256_sha256"
],
"type": "string"
},
"DeviceKeyCreateParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Create a controller-local device key with a random key id.",
"properties": {
"accountUserId": {
"type": "string"
},
"clientId": {
"type": "string"
},
"protectionPolicy": {
"anyOf": [
{
"$ref": "#/definitions/v2/DeviceKeyProtectionPolicy"
},
{
"type": "null"
}
],
"description": "Defaults to `hardware_only` when omitted."
}
},
"required": [
"accountUserId",
"clientId"
],
"title": "DeviceKeyCreateParams",
"type": "object"
},
"DeviceKeyCreateResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Device-key metadata and public key returned by create/public APIs.",
"properties": {
"algorithm": {
"$ref": "#/definitions/v2/DeviceKeyAlgorithm"
},
"keyId": {
"type": "string"
},
"protectionClass": {
"$ref": "#/definitions/v2/DeviceKeyProtectionClass"
},
"publicKeySpkiDerBase64": {
"description": "SubjectPublicKeyInfo DER encoded as base64.",
"type": "string"
}
},
"required": [
"algorithm",
"keyId",
"protectionClass",
"publicKeySpkiDerBase64"
],
"title": "DeviceKeyCreateResponse",
"type": "object"
},
"DeviceKeyProtectionClass": {
"description": "Platform protection class for a controller-local device key.",
"enum": [
"hardware_secure_enclave",
"hardware_tpm",
"os_protected_nonextractable"
],
"type": "string"
},
"DeviceKeyProtectionPolicy": {
"description": "Protection policy for creating or loading a controller-local device key.",
"enum": [
"hardware_only",
"allow_os_protected_nonextractable"
],
"type": "string"
},
"DeviceKeyPublicParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Fetch a controller-local device key public key by id.",
"properties": {
"keyId": {
"type": "string"
}
},
"required": [
"keyId"
],
"title": "DeviceKeyPublicParams",
"type": "object"
},
"DeviceKeyPublicResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Device-key public metadata returned by `device/key/public`.",
"properties": {
"algorithm": {
"$ref": "#/definitions/v2/DeviceKeyAlgorithm"
},
"keyId": {
"type": "string"
},
"protectionClass": {
"$ref": "#/definitions/v2/DeviceKeyProtectionClass"
},
"publicKeySpkiDerBase64": {
"description": "SubjectPublicKeyInfo DER encoded as base64.",
"type": "string"
}
},
"required": [
"algorithm",
"keyId",
"protectionClass",
"publicKeySpkiDerBase64"
],
"title": "DeviceKeyPublicResponse",
"type": "object"
},
"DeviceKeySignParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Sign an accepted structured payload with a controller-local device key.",
"properties": {
"keyId": {
"type": "string"
},
"payload": {
"$ref": "#/definitions/v2/DeviceKeySignPayload"
}
},
"required": [
"keyId",
"payload"
],
"title": "DeviceKeySignParams",
"type": "object"
},
"DeviceKeySignPayload": {
"description": "Structured payloads accepted by `device/key/sign`.",
"oneOf": [
{
"description": "Payload bound to one remote-control controller websocket `/client` connection challenge.",
"properties": {
"accountUserId": {
"type": "string"
},
"audience": {
"$ref": "#/definitions/v2/RemoteControlClientConnectionAudience"
},
"clientId": {
"type": "string"
},
"nonce": {
"type": "string"
},
"scopes": {
"description": "Must contain exactly `remote_control_controller_websocket`.",
"items": {
"type": "string"
},
"type": "array"
},
"sessionId": {
"description": "Backend-issued websocket session id that this proof authorizes.",
"type": "string"
},
"targetOrigin": {
"description": "Origin of the backend endpoint that issued the challenge and will verify this proof.",
"type": "string"
},
"targetPath": {
"description": "Websocket route path that this proof authorizes.",
"type": "string"
},
"tokenExpiresAt": {
"description": "Remote-control token expiration as Unix seconds.",
"format": "int64",
"type": "integer"
},
"tokenSha256Base64url": {
"description": "SHA-256 of the controller-scoped remote-control token, encoded as unpadded base64url.",
"type": "string"
},
"type": {
"enum": [
"remoteControlClientConnection"
],
"title": "RemoteControlClientConnectionDeviceKeySignPayloadType",
"type": "string"
}
},
"required": [
"accountUserId",
"audience",
"clientId",
"nonce",
"scopes",
"sessionId",
"targetOrigin",
"targetPath",
"tokenExpiresAt",
"tokenSha256Base64url",
"type"
],
"title": "RemoteControlClientConnectionDeviceKeySignPayload",
"type": "object"
},
{
"description": "Payload bound to a remote-control client `/client/enroll` ownership challenge.",
"properties": {
"accountUserId": {
"type": "string"
},
"audience": {
"$ref": "#/definitions/v2/RemoteControlClientEnrollmentAudience"
},
"challengeExpiresAt": {
"description": "Enrollment challenge expiration as Unix seconds.",
"format": "int64",
"type": "integer"
},
"challengeId": {
"description": "Backend-issued enrollment challenge id that this proof authorizes.",
"type": "string"
},
"clientId": {
"type": "string"
},
"deviceIdentitySha256Base64url": {
"description": "SHA-256 of the requested device identity operation, encoded as unpadded base64url.",
"type": "string"
},
"nonce": {
"type": "string"
},
"targetOrigin": {
"description": "Origin of the backend endpoint that issued the challenge and will verify this proof.",
"type": "string"
},
"targetPath": {
"description": "HTTP route path that this proof authorizes.",
"type": "string"
},
"type": {
"enum": [
"remoteControlClientEnrollment"
],
"title": "RemoteControlClientEnrollmentDeviceKeySignPayloadType",
"type": "string"
}
},
"required": [
"accountUserId",
"audience",
"challengeExpiresAt",
"challengeId",
"clientId",
"deviceIdentitySha256Base64url",
"nonce",
"targetOrigin",
"targetPath",
"type"
],
"title": "RemoteControlClientEnrollmentDeviceKeySignPayload",
"type": "object"
}
]
},
"DeviceKeySignResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "ASN.1 DER signature returned by `device/key/sign`.",
"properties": {
"algorithm": {
"$ref": "#/definitions/v2/DeviceKeyAlgorithm"
},
"signatureDerBase64": {
"description": "ECDSA signature DER encoded as base64.",
"type": "string"
},
"signedPayloadBase64": {
"description": "Exact bytes signed by the device key, encoded as base64. Verifiers must verify this byte string directly and must not reserialize `payload`.",
"type": "string"
}
},
"required": [
"algorithm",
"signatureDerBase64",
"signedPayloadBase64"
],
"title": "DeviceKeySignResponse",
"type": "object"
},
"DynamicToolCallOutputContentItem": {
"oneOf": [
{
@@ -11642,6 +12012,20 @@
"title": "ReasoningTextDeltaNotification",
"type": "object"
},
"RemoteControlClientConnectionAudience": {
"description": "Audience for a remote-control client connection device-key proof.",
"enum": [
"remote_control_client_websocket"
],
"type": "string"
},
"RemoteControlClientEnrollmentAudience": {
"description": "Audience for a remote-control client enrollment device-key proof.",
"enum": [
"remote_control_client_enrollment"
],
"type": "string"
},
"RequestId": {
"anyOf": [
{
@@ -1418,6 +1418,78 @@
"title": "App/listRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"device/key/create"
],
"title": "Device/key/createRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/DeviceKeyCreateParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Device/key/createRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"device/key/public"
],
"title": "Device/key/publicRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/DeviceKeyPublicParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Device/key/publicRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"device/key/sign"
],
"title": "Device/key/signRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/DeviceKeySignParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Device/key/signRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -3935,6 +4007,300 @@
"title": "DeprecationNoticeNotification",
"type": "object"
},
"DeviceKeyAlgorithm": {
"description": "Device-key algorithm reported at enrollment and signing boundaries.",
"enum": [
"ecdsa_p256_sha256"
],
"type": "string"
},
"DeviceKeyCreateParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Create a controller-local device key with a random key id.",
"properties": {
"accountUserId": {
"type": "string"
},
"clientId": {
"type": "string"
},
"protectionPolicy": {
"anyOf": [
{
"$ref": "#/definitions/DeviceKeyProtectionPolicy"
},
{
"type": "null"
}
],
"description": "Defaults to `hardware_only` when omitted."
}
},
"required": [
"accountUserId",
"clientId"
],
"title": "DeviceKeyCreateParams",
"type": "object"
},
"DeviceKeyCreateResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Device-key metadata and public key returned by create/public APIs.",
"properties": {
"algorithm": {
"$ref": "#/definitions/DeviceKeyAlgorithm"
},
"keyId": {
"type": "string"
},
"protectionClass": {
"$ref": "#/definitions/DeviceKeyProtectionClass"
},
"publicKeySpkiDerBase64": {
"description": "SubjectPublicKeyInfo DER encoded as base64.",
"type": "string"
}
},
"required": [
"algorithm",
"keyId",
"protectionClass",
"publicKeySpkiDerBase64"
],
"title": "DeviceKeyCreateResponse",
"type": "object"
},
"DeviceKeyProtectionClass": {
"description": "Platform protection class for a controller-local device key.",
"enum": [
"hardware_secure_enclave",
"hardware_tpm",
"os_protected_nonextractable"
],
"type": "string"
},
"DeviceKeyProtectionPolicy": {
"description": "Protection policy for creating or loading a controller-local device key.",
"enum": [
"hardware_only",
"allow_os_protected_nonextractable"
],
"type": "string"
},
"DeviceKeyPublicParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Fetch a controller-local device key public key by id.",
"properties": {
"keyId": {
"type": "string"
}
},
"required": [
"keyId"
],
"title": "DeviceKeyPublicParams",
"type": "object"
},
"DeviceKeyPublicResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Device-key public metadata returned by `device/key/public`.",
"properties": {
"algorithm": {
"$ref": "#/definitions/DeviceKeyAlgorithm"
},
"keyId": {
"type": "string"
},
"protectionClass": {
"$ref": "#/definitions/DeviceKeyProtectionClass"
},
"publicKeySpkiDerBase64": {
"description": "SubjectPublicKeyInfo DER encoded as base64.",
"type": "string"
}
},
"required": [
"algorithm",
"keyId",
"protectionClass",
"publicKeySpkiDerBase64"
],
"title": "DeviceKeyPublicResponse",
"type": "object"
},
"DeviceKeySignParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Sign an accepted structured payload with a controller-local device key.",
"properties": {
"keyId": {
"type": "string"
},
"payload": {
"$ref": "#/definitions/DeviceKeySignPayload"
}
},
"required": [
"keyId",
"payload"
],
"title": "DeviceKeySignParams",
"type": "object"
},
"DeviceKeySignPayload": {
"description": "Structured payloads accepted by `device/key/sign`.",
"oneOf": [
{
"description": "Payload bound to one remote-control controller websocket `/client` connection challenge.",
"properties": {
"accountUserId": {
"type": "string"
},
"audience": {
"$ref": "#/definitions/RemoteControlClientConnectionAudience"
},
"clientId": {
"type": "string"
},
"nonce": {
"type": "string"
},
"scopes": {
"description": "Must contain exactly `remote_control_controller_websocket`.",
"items": {
"type": "string"
},
"type": "array"
},
"sessionId": {
"description": "Backend-issued websocket session id that this proof authorizes.",
"type": "string"
},
"targetOrigin": {
"description": "Origin of the backend endpoint that issued the challenge and will verify this proof.",
"type": "string"
},
"targetPath": {
"description": "Websocket route path that this proof authorizes.",
"type": "string"
},
"tokenExpiresAt": {
"description": "Remote-control token expiration as Unix seconds.",
"format": "int64",
"type": "integer"
},
"tokenSha256Base64url": {
"description": "SHA-256 of the controller-scoped remote-control token, encoded as unpadded base64url.",
"type": "string"
},
"type": {
"enum": [
"remoteControlClientConnection"
],
"title": "RemoteControlClientConnectionDeviceKeySignPayloadType",
"type": "string"
}
},
"required": [
"accountUserId",
"audience",
"clientId",
"nonce",
"scopes",
"sessionId",
"targetOrigin",
"targetPath",
"tokenExpiresAt",
"tokenSha256Base64url",
"type"
],
"title": "RemoteControlClientConnectionDeviceKeySignPayload",
"type": "object"
},
{
"description": "Payload bound to a remote-control client `/client/enroll` ownership challenge.",
"properties": {
"accountUserId": {
"type": "string"
},
"audience": {
"$ref": "#/definitions/RemoteControlClientEnrollmentAudience"
},
"challengeExpiresAt": {
"description": "Enrollment challenge expiration as Unix seconds.",
"format": "int64",
"type": "integer"
},
"challengeId": {
"description": "Backend-issued enrollment challenge id that this proof authorizes.",
"type": "string"
},
"clientId": {
"type": "string"
},
"deviceIdentitySha256Base64url": {
"description": "SHA-256 of the requested device identity operation, encoded as unpadded base64url.",
"type": "string"
},
"nonce": {
"type": "string"
},
"targetOrigin": {
"description": "Origin of the backend endpoint that issued the challenge and will verify this proof.",
"type": "string"
},
"targetPath": {
"description": "HTTP route path that this proof authorizes.",
"type": "string"
},
"type": {
"enum": [
"remoteControlClientEnrollment"
],
"title": "RemoteControlClientEnrollmentDeviceKeySignPayloadType",
"type": "string"
}
},
"required": [
"accountUserId",
"audience",
"challengeExpiresAt",
"challengeId",
"clientId",
"deviceIdentitySha256Base64url",
"nonce",
"targetOrigin",
"targetPath",
"type"
],
"title": "RemoteControlClientEnrollmentDeviceKeySignPayload",
"type": "object"
}
]
},
"DeviceKeySignResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "ASN.1 DER signature returned by `device/key/sign`.",
"properties": {
"algorithm": {
"$ref": "#/definitions/DeviceKeyAlgorithm"
},
"signatureDerBase64": {
"description": "ECDSA signature DER encoded as base64.",
"type": "string"
},
"signedPayloadBase64": {
"description": "Exact bytes signed by the device key, encoded as base64. Verifiers must verify this byte string directly and must not reserialize `payload`.",
"type": "string"
}
},
"required": [
"algorithm",
"signatureDerBase64",
"signedPayloadBase64"
],
"title": "DeviceKeySignResponse",
"type": "object"
},
"DynamicToolCallOutputContentItem": {
"oneOf": [
{
@@ -8408,6 +8774,20 @@
"title": "ReasoningTextDeltaNotification",
"type": "object"
},
"RemoteControlClientConnectionAudience": {
"description": "Audience for a remote-control client connection device-key proof.",
"enum": [
"remote_control_client_websocket"
],
"type": "string"
},
"RemoteControlClientEnrollmentAudience": {
"description": "Audience for a remote-control client enrollment device-key proof.",
"enum": [
"remote_control_client_enrollment"
],
"type": "string"
},
"RequestId": {
"anyOf": [
{
@@ -0,0 +1,39 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"DeviceKeyProtectionPolicy": {
"description": "Protection policy for creating or loading a controller-local device key.",
"enum": [
"hardware_only",
"allow_os_protected_nonextractable"
],
"type": "string"
}
},
"description": "Create a controller-local device key with a random key id.",
"properties": {
"accountUserId": {
"type": "string"
},
"clientId": {
"type": "string"
},
"protectionPolicy": {
"anyOf": [
{
"$ref": "#/definitions/DeviceKeyProtectionPolicy"
},
{
"type": "null"
}
],
"description": "Defaults to `hardware_only` when omitted."
}
},
"required": [
"accountUserId",
"clientId"
],
"title": "DeviceKeyCreateParams",
"type": "object"
}
@@ -0,0 +1,45 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"DeviceKeyAlgorithm": {
"description": "Device-key algorithm reported at enrollment and signing boundaries.",
"enum": [
"ecdsa_p256_sha256"
],
"type": "string"
},
"DeviceKeyProtectionClass": {
"description": "Platform protection class for a controller-local device key.",
"enum": [
"hardware_secure_enclave",
"hardware_tpm",
"os_protected_nonextractable"
],
"type": "string"
}
},
"description": "Device-key metadata and public key returned by create/public APIs.",
"properties": {
"algorithm": {
"$ref": "#/definitions/DeviceKeyAlgorithm"
},
"keyId": {
"type": "string"
},
"protectionClass": {
"$ref": "#/definitions/DeviceKeyProtectionClass"
},
"publicKeySpkiDerBase64": {
"description": "SubjectPublicKeyInfo DER encoded as base64.",
"type": "string"
}
},
"required": [
"algorithm",
"keyId",
"protectionClass",
"publicKeySpkiDerBase64"
],
"title": "DeviceKeyCreateResponse",
"type": "object"
}
@@ -0,0 +1,14 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Fetch a controller-local device key public key by id.",
"properties": {
"keyId": {
"type": "string"
}
},
"required": [
"keyId"
],
"title": "DeviceKeyPublicParams",
"type": "object"
}
@@ -0,0 +1,45 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"DeviceKeyAlgorithm": {
"description": "Device-key algorithm reported at enrollment and signing boundaries.",
"enum": [
"ecdsa_p256_sha256"
],
"type": "string"
},
"DeviceKeyProtectionClass": {
"description": "Platform protection class for a controller-local device key.",
"enum": [
"hardware_secure_enclave",
"hardware_tpm",
"os_protected_nonextractable"
],
"type": "string"
}
},
"description": "Device-key public metadata returned by `device/key/public`.",
"properties": {
"algorithm": {
"$ref": "#/definitions/DeviceKeyAlgorithm"
},
"keyId": {
"type": "string"
},
"protectionClass": {
"$ref": "#/definitions/DeviceKeyProtectionClass"
},
"publicKeySpkiDerBase64": {
"description": "SubjectPublicKeyInfo DER encoded as base64.",
"type": "string"
}
},
"required": [
"algorithm",
"keyId",
"protectionClass",
"publicKeySpkiDerBase64"
],
"title": "DeviceKeyPublicResponse",
"type": "object"
}
@@ -0,0 +1,165 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"DeviceKeySignPayload": {
"description": "Structured payloads accepted by `device/key/sign`.",
"oneOf": [
{
"description": "Payload bound to one remote-control controller websocket `/client` connection challenge.",
"properties": {
"accountUserId": {
"type": "string"
},
"audience": {
"$ref": "#/definitions/RemoteControlClientConnectionAudience"
},
"clientId": {
"type": "string"
},
"nonce": {
"type": "string"
},
"scopes": {
"description": "Must contain exactly `remote_control_controller_websocket`.",
"items": {
"type": "string"
},
"type": "array"
},
"sessionId": {
"description": "Backend-issued websocket session id that this proof authorizes.",
"type": "string"
},
"targetOrigin": {
"description": "Origin of the backend endpoint that issued the challenge and will verify this proof.",
"type": "string"
},
"targetPath": {
"description": "Websocket route path that this proof authorizes.",
"type": "string"
},
"tokenExpiresAt": {
"description": "Remote-control token expiration as Unix seconds.",
"format": "int64",
"type": "integer"
},
"tokenSha256Base64url": {
"description": "SHA-256 of the controller-scoped remote-control token, encoded as unpadded base64url.",
"type": "string"
},
"type": {
"enum": [
"remoteControlClientConnection"
],
"title": "RemoteControlClientConnectionDeviceKeySignPayloadType",
"type": "string"
}
},
"required": [
"accountUserId",
"audience",
"clientId",
"nonce",
"scopes",
"sessionId",
"targetOrigin",
"targetPath",
"tokenExpiresAt",
"tokenSha256Base64url",
"type"
],
"title": "RemoteControlClientConnectionDeviceKeySignPayload",
"type": "object"
},
{
"description": "Payload bound to a remote-control client `/client/enroll` ownership challenge.",
"properties": {
"accountUserId": {
"type": "string"
},
"audience": {
"$ref": "#/definitions/RemoteControlClientEnrollmentAudience"
},
"challengeExpiresAt": {
"description": "Enrollment challenge expiration as Unix seconds.",
"format": "int64",
"type": "integer"
},
"challengeId": {
"description": "Backend-issued enrollment challenge id that this proof authorizes.",
"type": "string"
},
"clientId": {
"type": "string"
},
"deviceIdentitySha256Base64url": {
"description": "SHA-256 of the requested device identity operation, encoded as unpadded base64url.",
"type": "string"
},
"nonce": {
"type": "string"
},
"targetOrigin": {
"description": "Origin of the backend endpoint that issued the challenge and will verify this proof.",
"type": "string"
},
"targetPath": {
"description": "HTTP route path that this proof authorizes.",
"type": "string"
},
"type": {
"enum": [
"remoteControlClientEnrollment"
],
"title": "RemoteControlClientEnrollmentDeviceKeySignPayloadType",
"type": "string"
}
},
"required": [
"accountUserId",
"audience",
"challengeExpiresAt",
"challengeId",
"clientId",
"deviceIdentitySha256Base64url",
"nonce",
"targetOrigin",
"targetPath",
"type"
],
"title": "RemoteControlClientEnrollmentDeviceKeySignPayload",
"type": "object"
}
]
},
"RemoteControlClientConnectionAudience": {
"description": "Audience for a remote-control client connection device-key proof.",
"enum": [
"remote_control_client_websocket"
],
"type": "string"
},
"RemoteControlClientEnrollmentAudience": {
"description": "Audience for a remote-control client enrollment device-key proof.",
"enum": [
"remote_control_client_enrollment"
],
"type": "string"
}
},
"description": "Sign an accepted structured payload with a controller-local device key.",
"properties": {
"keyId": {
"type": "string"
},
"payload": {
"$ref": "#/definitions/DeviceKeySignPayload"
}
},
"required": [
"keyId",
"payload"
],
"title": "DeviceKeySignParams",
"type": "object"
}
@@ -0,0 +1,33 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"DeviceKeyAlgorithm": {
"description": "Device-key algorithm reported at enrollment and signing boundaries.",
"enum": [
"ecdsa_p256_sha256"
],
"type": "string"
}
},
"description": "ASN.1 DER signature returned by `device/key/sign`.",
"properties": {
"algorithm": {
"$ref": "#/definitions/DeviceKeyAlgorithm"
},
"signatureDerBase64": {
"description": "ECDSA signature DER encoded as base64.",
"type": "string"
},
"signedPayloadBase64": {
"description": "Exact bytes signed by the device key, encoded as base64. Verifiers must verify this byte string directly and must not reserialize `payload`.",
"type": "string"
}
},
"required": [
"algorithm",
"signatureDerBase64",
"signedPayloadBase64"
],
"title": "DeviceKeySignResponse",
"type": "object"
}
File diff suppressed because one or more lines are too long
@@ -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.
/**
* Device-key algorithm reported at enrollment and signing boundaries.
*/
export type DeviceKeyAlgorithm = "ecdsa_p256_sha256";
@@ -0,0 +1,13 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DeviceKeyProtectionPolicy } from "./DeviceKeyProtectionPolicy";
/**
* Create a controller-local device key with a random key id.
*/
export type DeviceKeyCreateParams = {
/**
* Defaults to `hardware_only` when omitted.
*/
protectionPolicy?: DeviceKeyProtectionPolicy | null, accountUserId: string, clientId: string, };
@@ -0,0 +1,14 @@
// 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 { DeviceKeyAlgorithm } from "./DeviceKeyAlgorithm";
import type { DeviceKeyProtectionClass } from "./DeviceKeyProtectionClass";
/**
* Device-key metadata and public key returned by create/public APIs.
*/
export type DeviceKeyCreateResponse = { keyId: string,
/**
* SubjectPublicKeyInfo DER encoded as base64.
*/
publicKeySpkiDerBase64: string, algorithm: DeviceKeyAlgorithm, protectionClass: DeviceKeyProtectionClass, };
@@ -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.
/**
* Platform protection class for a controller-local device key.
*/
export type DeviceKeyProtectionClass = "hardware_secure_enclave" | "hardware_tpm" | "os_protected_nonextractable";
@@ -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.
/**
* Protection policy for creating or loading a controller-local device key.
*/
export type DeviceKeyProtectionPolicy = "hardware_only" | "allow_os_protected_nonextractable";
@@ -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.
/**
* Fetch a controller-local device key public key by id.
*/
export type DeviceKeyPublicParams = { keyId: string, };
@@ -0,0 +1,14 @@
// 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 { DeviceKeyAlgorithm } from "./DeviceKeyAlgorithm";
import type { DeviceKeyProtectionClass } from "./DeviceKeyProtectionClass";
/**
* Device-key public metadata returned by `device/key/public`.
*/
export type DeviceKeyPublicResponse = { keyId: string,
/**
* SubjectPublicKeyInfo DER encoded as base64.
*/
publicKeySpkiDerBase64: string, algorithm: DeviceKeyAlgorithm, protectionClass: DeviceKeyProtectionClass, };
@@ -0,0 +1,9 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DeviceKeySignPayload } from "./DeviceKeySignPayload";
/**
* Sign an accepted structured payload with a controller-local device key.
*/
export type DeviceKeySignParams = { keyId: string, payload: DeviceKeySignPayload, };
@@ -0,0 +1,54 @@
// 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 { RemoteControlClientConnectionAudience } from "./RemoteControlClientConnectionAudience";
import type { RemoteControlClientEnrollmentAudience } from "./RemoteControlClientEnrollmentAudience";
/**
* Structured payloads accepted by `device/key/sign`.
*/
export type DeviceKeySignPayload = { "type": "remoteControlClientConnection", nonce: string, audience: RemoteControlClientConnectionAudience,
/**
* Backend-issued websocket session id that this proof authorizes.
*/
sessionId: string,
/**
* Origin of the backend endpoint that issued the challenge and will verify this proof.
*/
targetOrigin: string,
/**
* Websocket route path that this proof authorizes.
*/
targetPath: string, accountUserId: string, clientId: string,
/**
* Remote-control token expiration as Unix seconds.
*/
tokenExpiresAt: number,
/**
* SHA-256 of the controller-scoped remote-control token, encoded as unpadded base64url.
*/
tokenSha256Base64url: string,
/**
* Must contain exactly `remote_control_controller_websocket`.
*/
scopes: Array<string>, } | { "type": "remoteControlClientEnrollment", nonce: string, audience: RemoteControlClientEnrollmentAudience,
/**
* Backend-issued enrollment challenge id that this proof authorizes.
*/
challengeId: string,
/**
* Origin of the backend endpoint that issued the challenge and will verify this proof.
*/
targetOrigin: string,
/**
* HTTP route path that this proof authorizes.
*/
targetPath: string, accountUserId: string, clientId: string,
/**
* SHA-256 of the requested device identity operation, encoded as unpadded base64url.
*/
deviceIdentitySha256Base64url: string,
/**
* Enrollment challenge expiration as Unix seconds.
*/
challengeExpiresAt: number, };
@@ -0,0 +1,18 @@
// 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 { DeviceKeyAlgorithm } from "./DeviceKeyAlgorithm";
/**
* ASN.1 DER signature returned by `device/key/sign`.
*/
export type DeviceKeySignResponse = {
/**
* ECDSA signature DER encoded as base64.
*/
signatureDerBase64: string,
/**
* Exact bytes signed by the device key, encoded as base64. Verifiers must verify this byte
* string directly and must not reserialize `payload`.
*/
signedPayloadBase64: string, algorithm: DeviceKeyAlgorithm, };
@@ -1,6 +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 { AbsolutePathBuf } from "../AbsolutePathBuf";
import type { RequestPermissionProfile } from "./RequestPermissionProfile";
export type PermissionsRequestApprovalParams = { threadId: string, turnId: string, itemId: string, reason: string | null, permissions: RequestPermissionProfile, };
export type PermissionsRequestApprovalParams = { threadId: string, turnId: string, itemId: string, cwd: AbsolutePathBuf, reason: string | null, permissions: RequestPermissionProfile, };
@@ -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.
/**
* Audience for a remote-control client connection device-key proof.
*/
export type RemoteControlClientConnectionAudience = "remote_control_client_websocket";
@@ -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.
/**
* Audience for a remote-control client enrollment device-key proof.
*/
export type RemoteControlClientEnrollmentAudience = "remote_control_client_enrollment";
@@ -74,6 +74,16 @@ export type { ConfigWriteResponse } from "./ConfigWriteResponse";
export type { ContextCompactedNotification } from "./ContextCompactedNotification";
export type { CreditsSnapshot } from "./CreditsSnapshot";
export type { DeprecationNoticeNotification } from "./DeprecationNoticeNotification";
export type { DeviceKeyAlgorithm } from "./DeviceKeyAlgorithm";
export type { DeviceKeyCreateParams } from "./DeviceKeyCreateParams";
export type { DeviceKeyCreateResponse } from "./DeviceKeyCreateResponse";
export type { DeviceKeyProtectionClass } from "./DeviceKeyProtectionClass";
export type { DeviceKeyProtectionPolicy } from "./DeviceKeyProtectionPolicy";
export type { DeviceKeyPublicParams } from "./DeviceKeyPublicParams";
export type { DeviceKeyPublicResponse } from "./DeviceKeyPublicResponse";
export type { DeviceKeySignParams } from "./DeviceKeySignParams";
export type { DeviceKeySignPayload } from "./DeviceKeySignPayload";
export type { DeviceKeySignResponse } from "./DeviceKeySignResponse";
export type { DynamicToolCallOutputContentItem } from "./DynamicToolCallOutputContentItem";
export type { DynamicToolCallParams } from "./DynamicToolCallParams";
export type { DynamicToolCallResponse } from "./DynamicToolCallResponse";
@@ -259,6 +269,8 @@ export type { ReasoningEffortOption } from "./ReasoningEffortOption";
export type { ReasoningSummaryPartAddedNotification } from "./ReasoningSummaryPartAddedNotification";
export type { ReasoningSummaryTextDeltaNotification } from "./ReasoningSummaryTextDeltaNotification";
export type { ReasoningTextDeltaNotification } from "./ReasoningTextDeltaNotification";
export type { RemoteControlClientConnectionAudience } from "./RemoteControlClientConnectionAudience";
export type { RemoteControlClientEnrollmentAudience } from "./RemoteControlClientEnrollmentAudience";
export type { RequestPermissionProfile } from "./RequestPermissionProfile";
export type { ResidencyRequirement } from "./ResidencyRequirement";
export type { ReviewDelivery } from "./ReviewDelivery";
@@ -360,6 +360,18 @@ client_request_definitions! {
params: v2::AppsListParams,
response: v2::AppsListResponse,
},
DeviceKeyCreate => "device/key/create" {
params: v2::DeviceKeyCreateParams,
response: v2::DeviceKeyCreateResponse,
},
DeviceKeyPublic => "device/key/public" {
params: v2::DeviceKeyPublicParams,
response: v2::DeviceKeyPublicResponse,
},
DeviceKeySign => "device/key/sign" {
params: v2::DeviceKeySignParams,
response: v2::DeviceKeySignResponse,
},
FsReadFile => "fs/readFile" {
params: v2::FsReadFileParams,
response: v2::FsReadFileResponse,
@@ -2494,6 +2494,164 @@ pub struct FeedbackUploadResponse {
pub thread_id: String,
}
/// Device-key algorithm reported at enrollment and signing boundaries.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(rename_all = "snake_case", export_to = "v2/")]
pub enum DeviceKeyAlgorithm {
EcdsaP256Sha256,
}
/// Platform protection class for a controller-local device key.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(rename_all = "snake_case", export_to = "v2/")]
pub enum DeviceKeyProtectionClass {
HardwareSecureEnclave,
HardwareTpm,
OsProtectedNonextractable,
}
/// Protection policy for creating or loading a controller-local device key.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(rename_all = "snake_case", export_to = "v2/")]
pub enum DeviceKeyProtectionPolicy {
HardwareOnly,
AllowOsProtectedNonextractable,
}
/// Create a controller-local device key with a random key id.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct DeviceKeyCreateParams {
/// Defaults to `hardware_only` when omitted.
#[ts(optional = nullable)]
pub protection_policy: Option<DeviceKeyProtectionPolicy>,
pub account_user_id: String,
pub client_id: String,
}
/// Device-key metadata and public key returned by create/public APIs.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct DeviceKeyCreateResponse {
pub key_id: String,
/// SubjectPublicKeyInfo DER encoded as base64.
pub public_key_spki_der_base64: String,
pub algorithm: DeviceKeyAlgorithm,
pub protection_class: DeviceKeyProtectionClass,
}
/// Fetch a controller-local device key public key by id.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct DeviceKeyPublicParams {
pub key_id: String,
}
/// Device-key public metadata returned by `device/key/public`.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct DeviceKeyPublicResponse {
pub key_id: String,
/// SubjectPublicKeyInfo DER encoded as base64.
pub public_key_spki_der_base64: String,
pub algorithm: DeviceKeyAlgorithm,
pub protection_class: DeviceKeyProtectionClass,
}
/// Audience for a remote-control client connection device-key proof.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(rename_all = "snake_case", export_to = "v2/")]
pub enum RemoteControlClientConnectionAudience {
RemoteControlClientWebsocket,
}
/// Audience for a remote-control client enrollment device-key proof.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(rename_all = "snake_case", export_to = "v2/")]
pub enum RemoteControlClientEnrollmentAudience {
RemoteControlClientEnrollment,
}
/// Structured payloads accepted by `device/key/sign`.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "camelCase")]
#[ts(tag = "type", export_to = "v2/")]
pub enum DeviceKeySignPayload {
/// Payload bound to one remote-control controller websocket `/client` connection challenge.
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
RemoteControlClientConnection {
nonce: String,
audience: RemoteControlClientConnectionAudience,
/// Backend-issued websocket session id that this proof authorizes.
session_id: String,
/// Origin of the backend endpoint that issued the challenge and will verify this proof.
target_origin: String,
/// Websocket route path that this proof authorizes.
target_path: String,
account_user_id: String,
client_id: String,
/// Remote-control token expiration as Unix seconds.
#[ts(type = "number")]
token_expires_at: i64,
/// SHA-256 of the controller-scoped remote-control token, encoded as unpadded base64url.
token_sha256_base64url: String,
/// Must contain exactly `remote_control_controller_websocket`.
scopes: Vec<String>,
},
/// Payload bound to a remote-control client `/client/enroll` ownership challenge.
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
RemoteControlClientEnrollment {
nonce: String,
audience: RemoteControlClientEnrollmentAudience,
/// Backend-issued enrollment challenge id that this proof authorizes.
challenge_id: String,
/// Origin of the backend endpoint that issued the challenge and will verify this proof.
target_origin: String,
/// HTTP route path that this proof authorizes.
target_path: String,
account_user_id: String,
client_id: String,
/// SHA-256 of the requested device identity operation, encoded as unpadded base64url.
device_identity_sha256_base64url: String,
/// Enrollment challenge expiration as Unix seconds.
#[ts(type = "number")]
challenge_expires_at: i64,
},
}
/// Sign an accepted structured payload with a controller-local device key.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct DeviceKeySignParams {
pub key_id: String,
pub payload: DeviceKeySignPayload,
}
/// ASN.1 DER signature returned by `device/key/sign`.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct DeviceKeySignResponse {
/// ECDSA signature DER encoded as base64.
pub signature_der_base64: String,
/// Exact bytes signed by the device key, encoded as base64. Verifiers must verify this byte
/// string directly and must not reserialize `payload`.
pub signed_payload_base64: String,
pub algorithm: DeviceKeyAlgorithm,
}
/// Read a file from the host filesystem.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
@@ -6669,6 +6827,7 @@ pub struct PermissionsRequestApprovalParams {
pub thread_id: String,
pub turn_id: String,
pub item_id: String,
pub cwd: AbsolutePathBuf,
pub reason: Option<String>,
pub permissions: RequestPermissionProfile,
}
@@ -7103,6 +7262,7 @@ mod tests {
"threadId": "thr_123",
"turnId": "turn_123",
"itemId": "call_123",
"cwd": absolute_path_string("repo"),
"reason": "Select a workspace root",
"permissions": {
"network": {
@@ -7116,6 +7276,7 @@ mod tests {
}))
.expect("permissions request should deserialize");
assert_eq!(params.cwd, absolute_path("repo"));
assert_eq!(
params.permissions,
RequestPermissionProfile {
@@ -7163,6 +7324,7 @@ mod tests {
"threadId": "thr_123",
"turnId": "turn_123",
"itemId": "call_123",
"cwd": absolute_path_string("repo"),
"reason": "Select a workspace root",
"permissions": {
"network": null,
@@ -7387,6 +7549,181 @@ mod tests {
assert_eq!(decoded, params);
}
#[test]
fn device_key_create_params_round_trip_uses_protection_policy() {
let params = DeviceKeyCreateParams {
protection_policy: None,
account_user_id: "account-user-1".to_string(),
client_id: "cli_123".to_string(),
};
let value = serde_json::to_value(&params).expect("serialize device/key/create params");
assert_eq!(
value,
json!({
"accountUserId": "account-user-1",
"clientId": "cli_123",
"protectionPolicy": null,
})
);
let decoded = serde_json::from_value::<DeviceKeyCreateParams>(value)
.expect("deserialize device/key/create params");
assert_eq!(decoded, params);
let params = DeviceKeyCreateParams {
protection_policy: Some(DeviceKeyProtectionPolicy::AllowOsProtectedNonextractable),
account_user_id: "account-user-1".to_string(),
client_id: "cli_123".to_string(),
};
let value = serde_json::to_value(&params)
.expect("serialize device/key/create params with protection policy");
assert_eq!(
value,
json!({
"accountUserId": "account-user-1",
"clientId": "cli_123",
"protectionPolicy": "allow_os_protected_nonextractable",
})
);
}
#[test]
fn device_key_create_response_round_trips_protection_class() {
let response = DeviceKeyCreateResponse {
key_id: "dk_123".to_string(),
public_key_spki_der_base64: "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE".to_string(),
algorithm: DeviceKeyAlgorithm::EcdsaP256Sha256,
protection_class: DeviceKeyProtectionClass::OsProtectedNonextractable,
};
let value = serde_json::to_value(&response).expect("serialize device/key/create response");
assert_eq!(
value,
json!({
"keyId": "dk_123",
"publicKeySpkiDerBase64": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE",
"algorithm": "ecdsa_p256_sha256",
"protectionClass": "os_protected_nonextractable",
})
);
let decoded = serde_json::from_value::<DeviceKeyCreateResponse>(value)
.expect("deserialize device/key/create response");
assert_eq!(decoded, response);
}
#[test]
fn device_key_sign_params_round_trip_uses_accepted_payload_enum() {
let params = DeviceKeySignParams {
key_id: "dk_123".to_string(),
payload: DeviceKeySignPayload::RemoteControlClientConnection {
nonce: "nonce-1".to_string(),
audience: RemoteControlClientConnectionAudience::RemoteControlClientWebsocket,
session_id: "wssess_123".to_string(),
target_origin: "https://chatgpt.com".to_string(),
target_path: "/api/codex/remote/control/client".to_string(),
account_user_id: "account-user-1".to_string(),
client_id: "cli_123".to_string(),
token_sha256_base64url: "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU".to_string(),
token_expires_at: 1_700_000_000,
scopes: vec!["remote_control_controller_websocket".to_string()],
},
};
let value = serde_json::to_value(&params).expect("serialize device/key/sign params");
assert_eq!(
value,
json!({
"keyId": "dk_123",
"payload": {
"type": "remoteControlClientConnection",
"nonce": "nonce-1",
"audience": "remote_control_client_websocket",
"sessionId": "wssess_123",
"targetOrigin": "https://chatgpt.com",
"targetPath": "/api/codex/remote/control/client",
"accountUserId": "account-user-1",
"clientId": "cli_123",
"tokenSha256Base64url": "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU",
"tokenExpiresAt": 1_700_000_000,
"scopes": ["remote_control_controller_websocket"],
},
})
);
let decoded = serde_json::from_value::<DeviceKeySignParams>(value)
.expect("deserialize device/key/sign params");
assert_eq!(decoded, params);
}
#[test]
fn device_key_sign_params_round_trip_uses_enrollment_payload() {
let params = DeviceKeySignParams {
key_id: "dk_123".to_string(),
payload: DeviceKeySignPayload::RemoteControlClientEnrollment {
nonce: "nonce-1".to_string(),
audience: RemoteControlClientEnrollmentAudience::RemoteControlClientEnrollment,
challenge_id: "rch_123".to_string(),
target_origin: "https://chatgpt.com".to_string(),
target_path: "/wham/remote/control/client/enroll".to_string(),
account_user_id: "account-user-1".to_string(),
client_id: "cli_123".to_string(),
device_identity_sha256_base64url: "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU"
.to_string(),
challenge_expires_at: 1_700_000_000,
},
};
let value = serde_json::to_value(&params)
.expect("serialize device/key/sign params with enrollment payload");
assert_eq!(
value,
json!({
"keyId": "dk_123",
"payload": {
"type": "remoteControlClientEnrollment",
"nonce": "nonce-1",
"audience": "remote_control_client_enrollment",
"challengeId": "rch_123",
"targetOrigin": "https://chatgpt.com",
"targetPath": "/wham/remote/control/client/enroll",
"accountUserId": "account-user-1",
"clientId": "cli_123",
"deviceIdentitySha256Base64url": "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU",
"challengeExpiresAt": 1_700_000_000,
},
})
);
let decoded = serde_json::from_value::<DeviceKeySignParams>(value)
.expect("deserialize device/key/sign params with enrollment payload");
assert_eq!(decoded, params);
}
#[test]
fn device_key_sign_response_returns_signed_payload_bytes() {
let response = DeviceKeySignResponse {
signature_der_base64: "MEUCIQD".to_string(),
signed_payload_base64: "eyJkb21haW4iOiJjb2RleA".to_string(),
algorithm: DeviceKeyAlgorithm::EcdsaP256Sha256,
};
let value = serde_json::to_value(&response).expect("serialize device/key/sign response");
assert_eq!(
value,
json!({
"signatureDerBase64": "MEUCIQD",
"signedPayloadBase64": "eyJkb21haW4iOiJjb2RleA",
"algorithm": "ecdsa_p256_sha256",
})
);
let decoded = serde_json::from_value::<DeviceKeySignResponse>(value)
.expect("deserialize device/key/sign response");
assert_eq!(decoded, response);
}
#[test]
fn fs_create_directory_params_round_trip_with_default_recursive() {
let params = FsCreateDirectoryParams {
+2
View File
@@ -72,8 +72,10 @@ serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
sha2 = { workspace = true }
tempfile = { workspace = true }
thiserror = { workspace = true }
time = { workspace = true }
toml = { workspace = true }
toml_edit = { workspace = true }
tokio = { workspace = true, features = [
"io-std",
"macros",
+2 -1
View File
@@ -1148,7 +1148,7 @@ the client can offer session-scoped and/or persistent approval choices.
### Permission requests
The built-in `request_permissions` tool sends an `item/permissions/requestApproval` JSON-RPC request to the client with the requested permission profile. This v2 payload mirrors the command-execution `additionalPermissions` shape: it can request network access and additional filesystem access.
The built-in `request_permissions` tool sends an `item/permissions/requestApproval` JSON-RPC request to the client with the requested permission profile. This v2 payload mirrors the command-execution `additionalPermissions` shape: it can request network access and additional filesystem access. The `cwd` field identifies the directory used to resolve cwd-relative permissions such as `:cwd`, `:project_roots`, and relative deny globs.
```json
{
@@ -1158,6 +1158,7 @@ The built-in `request_permissions` tool sends an `item/permissions/requestApprov
"threadId": "thr_123",
"turnId": "turn_123",
"itemId": "call_123",
"cwd": "/Users/me/project",
"reason": "Select a workspace root",
"permissions": {
"fileSystem": {
+178 -23
View File
@@ -120,6 +120,7 @@ use codex_protocol::ThreadId;
use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem as CoreDynamicToolCallOutputContentItem;
use codex_protocol::dynamic_tools::DynamicToolResponse as CoreDynamicToolResponse;
use codex_protocol::items::parse_hook_prompt_message;
use codex_protocol::models::PermissionProfile as CorePermissionProfile;
use codex_protocol::plan_tool::UpdatePlanArgs;
use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo;
use codex_protocol::protocol::Event;
@@ -908,27 +909,32 @@ pub(crate) async fn apply_bespoke_event_handling(
.note_permission_requested(&conversation_id.to_string())
.await;
let requested_permissions = request.permissions.clone();
let request_cwd = match request.cwd.clone() {
Some(cwd) => cwd,
None => conversation.config_snapshot().await.cwd,
};
let params = PermissionsRequestApprovalParams {
thread_id: conversation_id.to_string(),
turn_id: request.turn_id.clone(),
item_id: request.call_id.clone(),
cwd: request_cwd.clone(),
reason: request.reason,
permissions: request.permissions.into(),
};
let (pending_request_id, rx) = outgoing
.send_request(ServerRequestPayload::PermissionsRequestApproval(params))
.await;
let pending_response = PendingRequestPermissionsResponse {
call_id: request.call_id,
requested_permissions,
request_cwd,
pending_request_id,
receiver: rx,
request_permissions_guard: permission_guard,
};
tokio::spawn(async move {
on_request_permissions_response(
request.call_id,
requested_permissions,
pending_request_id,
rx,
conversation,
thread_state,
permission_guard,
)
.await;
on_request_permissions_response(pending_response, conversation, thread_state)
.await;
});
} else {
error!(
@@ -2706,20 +2712,26 @@ fn mcp_server_elicitation_response_from_client_result(
}
async fn on_request_permissions_response(
call_id: String,
requested_permissions: CoreRequestPermissionProfile,
pending_request_id: RequestId,
receiver: oneshot::Receiver<ClientRequestResult>,
pending_response: PendingRequestPermissionsResponse,
conversation: Arc<CodexThread>,
thread_state: Arc<Mutex<ThreadState>>,
request_permissions_guard: ThreadWatchActiveGuard,
) {
let PendingRequestPermissionsResponse {
call_id,
requested_permissions,
request_cwd,
pending_request_id,
receiver,
request_permissions_guard,
} = pending_response;
let response = receiver.await;
resolve_server_request_on_thread_listener(&thread_state, pending_request_id).await;
drop(request_permissions_guard);
let Some(response) =
request_permissions_response_from_client_result(requested_permissions, response)
else {
let Some(response) = request_permissions_response_from_client_result(
requested_permissions,
response,
request_cwd.as_path(),
) else {
return;
};
@@ -2734,9 +2746,19 @@ async fn on_request_permissions_response(
}
}
struct PendingRequestPermissionsResponse {
call_id: String,
requested_permissions: CoreRequestPermissionProfile,
request_cwd: AbsolutePathBuf,
pending_request_id: RequestId,
receiver: oneshot::Receiver<ClientRequestResult>,
request_permissions_guard: ThreadWatchActiveGuard,
}
fn request_permissions_response_from_client_result(
requested_permissions: CoreRequestPermissionProfile,
response: std::result::Result<ClientRequestResult, oneshot::error::RecvError>,
cwd: &std::path::Path,
) -> Option<CoreRequestPermissionsResponse> {
let value = match response {
Ok(Ok(value)) => value,
@@ -2765,12 +2787,14 @@ fn request_permissions_response_from_client_result(
scope: codex_app_server_protocol::PermissionGrantScope::Turn,
}
});
let granted_permissions: CorePermissionProfile = response.permissions.into();
let permissions = if granted_permissions.is_empty() {
CoreRequestPermissionProfile::default()
} else {
intersect_permission_profiles(requested_permissions.into(), granted_permissions, cwd).into()
};
Some(CoreRequestPermissionsResponse {
permissions: intersect_permission_profiles(
requested_permissions.into(),
response.permissions.into(),
)
.into(),
permissions,
scope: response.scope.to_core(),
})
}
@@ -3144,6 +3168,10 @@ mod tests {
use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions;
use codex_protocol::models::MessagePhase;
use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::plan_tool::PlanItemArg;
use codex_protocol::plan_tool::StepStatus;
use codex_protocol::protocol::CollabResumeBeginEvent;
@@ -3828,6 +3856,7 @@ mod tests {
let response = request_permissions_response_from_client_result(
CoreRequestPermissionProfile::default(),
Ok(Err(error)),
std::env::current_dir().expect("current dir").as_path(),
);
assert_eq!(response, None);
@@ -3914,12 +3943,14 @@ mod tests {
),
];
let cwd = std::env::current_dir().expect("current dir");
for (granted_permissions, expected_permissions) in cases {
let response = request_permissions_response_from_client_result(
requested_permissions.clone(),
Ok(Ok(serde_json::json!({
"permissions": granted_permissions,
}))),
cwd.as_path(),
)
.expect("response should be accepted");
@@ -3941,6 +3972,7 @@ mod tests {
"scope": "session",
"permissions": {},
}))),
std::env::current_dir().expect("current dir").as_path(),
)
.expect("response should be accepted");
@@ -3953,6 +3985,129 @@ mod tests {
);
}
#[test]
fn request_permissions_response_accepts_explicit_child_grant_for_requested_cwd_scope() {
let temp_dir = TempDir::new().expect("temp dir");
let cwd = AbsolutePathBuf::from_absolute_path(temp_dir.path()).expect("absolute cwd");
let child = cwd.join("child");
let requested_permissions = CoreRequestPermissionProfile {
file_system: Some(CoreFileSystemPermissions {
entries: vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::CurrentWorkingDirectory,
},
access: FileSystemAccessMode::Write,
}],
glob_scan_max_depth: None,
}),
..Default::default()
};
let response = request_permissions_response_from_client_result(
requested_permissions,
Ok(Ok(serde_json::json!({
"permissions": {
"fileSystem": {
"write": [child],
},
},
}))),
cwd.as_path(),
)
.expect("response should be accepted");
assert_eq!(
response.permissions,
CoreRequestPermissionProfile {
file_system: Some(CoreFileSystemPermissions::from_read_write_roots(
/*read*/ None,
Some(vec![child]),
)),
..Default::default()
}
);
}
#[test]
fn request_permissions_response_rejects_child_grant_outside_requested_cwd_scope() {
let temp_dir = TempDir::new().expect("temp dir");
let request_cwd = AbsolutePathBuf::from_absolute_path(temp_dir.path().join("request-cwd"))
.expect("absolute request cwd");
let later_cwd = AbsolutePathBuf::from_absolute_path(temp_dir.path().join("later-cwd"))
.expect("absolute later cwd");
let later_child = later_cwd.join("child");
let requested_permissions = CoreRequestPermissionProfile {
file_system: Some(CoreFileSystemPermissions {
entries: vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::CurrentWorkingDirectory,
},
access: FileSystemAccessMode::Write,
}],
glob_scan_max_depth: None,
}),
..Default::default()
};
let response = request_permissions_response_from_client_result(
requested_permissions,
Ok(Ok(serde_json::json!({
"permissions": {
"fileSystem": {
"write": [later_child],
},
},
}))),
request_cwd.as_path(),
)
.expect("response should be accepted");
assert_eq!(
response.permissions,
CoreRequestPermissionProfile::default()
);
}
#[test]
fn request_permissions_response_ignores_broader_cwd_grant_for_requested_child_path() {
let temp_dir = TempDir::new().expect("temp dir");
let cwd = AbsolutePathBuf::from_absolute_path(temp_dir.path()).expect("absolute cwd");
let child = cwd.join("child");
let requested_permissions = CoreRequestPermissionProfile {
file_system: Some(CoreFileSystemPermissions::from_read_write_roots(
/*read*/ None,
Some(vec![child]),
)),
..Default::default()
};
let response = request_permissions_response_from_client_result(
requested_permissions,
Ok(Ok(serde_json::json!({
"permissions": {
"fileSystem": {
"entries": [{
"path": {
"type": "special",
"value": {
"kind": "current_working_directory"
}
},
"access": "write"
}],
},
},
}))),
cwd.as_path(),
)
.expect("response should be accepted");
assert_eq!(
response.permissions,
CoreRequestPermissionProfile::default()
);
}
#[test]
fn collab_resume_begin_maps_to_item_started_resume_agent() {
let event = CollabResumeBeginEvent {
+127 -307
View File
@@ -2,7 +2,7 @@ use crate::bespoke_event_handling::apply_bespoke_event_handling;
use crate::bespoke_event_handling::maybe_emit_hook_prompt_item_completed;
use crate::command_exec::CommandExecManager;
use crate::command_exec::StartCommandExecParams;
use crate::config_api::apply_runtime_feature_enablement;
use crate::config_manager::ConfigManager;
use crate::error_code::INPUT_TOO_LARGE_ERROR_CODE;
use crate::error_code::INTERNAL_ERROR_CODE;
use crate::error_code::INVALID_PARAMS_ERROR_CODE;
@@ -212,8 +212,6 @@ use codex_arg0::Arg0DispatchPaths;
use codex_backend_client::AddCreditsNudgeCreditType as BackendAddCreditsNudgeCreditType;
use codex_backend_client::Client as BackendClient;
use codex_chatgpt::connectors;
use codex_cloud_requirements::cloud_requirements_loader;
use codex_config::ThreadConfigLoader;
use codex_config::types::McpServerTransportConfig;
use codex_core::CodexThread;
use codex_core::ForkSnapshot;
@@ -231,9 +229,6 @@ use codex_core::config::edit::ConfigEdit;
use codex_core::config::edit::ConfigEditsBuilder;
use codex_core::config_loader::CloudRequirementsLoadError;
use codex_core::config_loader::CloudRequirementsLoadErrorCode;
use codex_core::config_loader::CloudRequirementsLoader;
use codex_core::config_loader::LoaderOverrides;
use codex_core::config_loader::load_config_layers_state;
use codex_core::config_loader::project_trust_key;
use codex_core::exec::ExecCapturePolicy;
use codex_core::exec::ExecExpiration;
@@ -280,7 +275,6 @@ use codex_login::ServerOptions as LoginServerOptions;
use codex_login::ShutdownHandle;
use codex_login::auth::login_with_chatgpt_auth_tokens;
use codex_login::complete_device_code_login;
use codex_login::default_client::set_default_client_residency_requirement;
use codex_login::login_with_api_key;
use codex_login::request_device_code;
use codex_login::run_login_server;
@@ -348,16 +342,13 @@ use codex_thread_store::ThreadStore;
use codex_thread_store::ThreadStoreError;
use codex_thread_store::UpdateThreadMetadataParams as StoreUpdateThreadMetadataParams;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_json_to_toml::json_to_toml;
use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::collections::HashSet;
use std::io::Error as IoError;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::RwLock;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use std::time::Duration;
@@ -478,10 +469,7 @@ pub(crate) struct CodexMessageProcessor {
arg0_paths: Arg0DispatchPaths,
config: Arc<Config>,
thread_store: Arc<dyn ThreadStore>,
cli_overrides: Arc<RwLock<Vec<(String, TomlValue)>>>,
runtime_feature_enablement: Arc<RwLock<BTreeMap<String, bool>>>,
cloud_requirements: Arc<RwLock<CloudRequirementsLoader>>,
thread_config_loader: Arc<dyn ThreadConfigLoader>,
config_manager: ConfigManager,
active_login: Arc<Mutex<Option<ActiveLogin>>>,
pending_thread_unloads: Arc<Mutex<HashSet<ThreadId>>>,
thread_state_manager: ThreadStateManager,
@@ -638,11 +626,10 @@ pub(crate) struct CodexMessageProcessorArgs {
pub(crate) outgoing: Arc<OutgoingMessageSender>,
pub(crate) analytics_events_client: AnalyticsEventsClient,
pub(crate) arg0_paths: Arg0DispatchPaths,
/// Startup config used as the process baseline. Fresh effective config loads
/// go through `config_manager`.
pub(crate) config: Arc<Config>,
pub(crate) cli_overrides: Arc<RwLock<Vec<(String, TomlValue)>>>,
pub(crate) runtime_feature_enablement: Arc<RwLock<BTreeMap<String, bool>>>,
pub(crate) cloud_requirements: Arc<RwLock<CloudRequirementsLoader>>,
pub(crate) thread_config_loader: Arc<dyn ThreadConfigLoader>,
pub(crate) config_manager: ConfigManager,
pub(crate) feedback: CodexFeedback,
pub(crate) log_db: Option<LogDbLayer>,
}
@@ -727,10 +714,7 @@ impl CodexMessageProcessor {
analytics_events_client,
arg0_paths,
config,
cli_overrides,
runtime_feature_enablement,
cloud_requirements,
thread_config_loader,
config_manager,
feedback,
log_db,
} = args;
@@ -742,10 +726,7 @@ impl CodexMessageProcessor {
arg0_paths,
thread_store: configured_thread_store(&config),
config,
cli_overrides,
runtime_feature_enablement,
cloud_requirements,
thread_config_loader,
config_manager,
active_login: Arc::new(Mutex::new(None)),
pending_thread_unloads: Arc::new(Mutex::new(HashSet::new())),
thread_state_manager: ThreadStateManager::new(),
@@ -763,44 +744,14 @@ impl CodexMessageProcessor {
&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.current_cli_overrides())
.fallback_cwd(fallback_cwd)
.cloud_requirements(cloud_requirements)
.build()
self.config_manager
.load_latest_config(fallback_cwd)
.await
.map_err(|err| JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to reload config: {err}"),
data: None,
})?;
apply_runtime_feature_enablement(&mut config, &self.current_runtime_feature_enablement());
config.codex_self_exe = self.arg0_paths.codex_self_exe.clone();
config.codex_linux_sandbox_exe = self.arg0_paths.codex_linux_sandbox_exe.clone();
config.main_execve_wrapper_exe = self.arg0_paths.main_execve_wrapper_exe.clone();
Ok(config)
}
fn current_cloud_requirements(&self) -> CloudRequirementsLoader {
self.cloud_requirements
.read()
.map(|guard| guard.clone())
.unwrap_or_default()
}
fn current_cli_overrides(&self) -> Vec<(String, TomlValue)> {
self.cli_overrides
.read()
.map(|guard| guard.clone())
.unwrap_or_default()
}
fn current_runtime_feature_enablement(&self) -> BTreeMap<String, bool> {
self.runtime_feature_enablement
.read()
.map(|guard| guard.clone())
.unwrap_or_default()
})
}
/// If a client sends `developer_instructions: null` during a mode switch,
@@ -1186,6 +1137,11 @@ impl CodexMessageProcessor {
self.command_exec_terminate(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::DeviceKeyCreate { .. }
| ClientRequest::DeviceKeyPublic { .. }
| ClientRequest::DeviceKeySign { .. } => {
warn!("Device key request reached CodexMessageProcessor unexpectedly");
}
ClientRequest::ConfigRead { .. }
| ClientRequest::ConfigValueWrite { .. }
| ClientRequest::ConfigBatchWrite { .. }
@@ -1419,10 +1375,8 @@ impl CodexMessageProcessor {
let outgoing_clone = self.outgoing.clone();
let active_login = self.active_login.clone();
let auth_manager = self.auth_manager.clone();
let cloud_requirements = self.cloud_requirements.clone();
let config_manager = self.config_manager.clone();
let chatgpt_base_url = self.config.chatgpt_base_url.clone();
let codex_home = self.config.codex_home.to_path_buf();
let cli_overrides = self.current_cli_overrides();
let auth_url = server.auth_url.clone();
tokio::spawn(async move {
let (success, error_msg) = match tokio::time::timeout(
@@ -1452,17 +1406,13 @@ impl CodexMessageProcessor {
if success {
auth_manager.reload();
replace_cloud_requirements_loader(
cloud_requirements.as_ref(),
config_manager.replace_cloud_requirements_loader(
auth_manager.clone(),
chatgpt_base_url,
codex_home,
);
sync_default_client_residency_requirement(
&cli_overrides,
cloud_requirements.as_ref(),
)
.await;
config_manager
.sync_default_client_residency_requirement()
.await;
// Notify clients with the actual current auth mode.
let auth = auth_manager.auth_cached();
@@ -1536,10 +1486,8 @@ impl CodexMessageProcessor {
let outgoing_clone = self.outgoing.clone();
let active_login = self.active_login.clone();
let auth_manager = self.auth_manager.clone();
let cloud_requirements = self.cloud_requirements.clone();
let config_manager = self.config_manager.clone();
let chatgpt_base_url = self.config.chatgpt_base_url.clone();
let codex_home = self.config.codex_home.to_path_buf();
let cli_overrides = self.current_cli_overrides();
tokio::spawn(async move {
let (success, error_msg) = tokio::select! {
_ = cancel.cancelled() => {
@@ -1566,17 +1514,13 @@ impl CodexMessageProcessor {
if success {
auth_manager.reload();
replace_cloud_requirements_loader(
cloud_requirements.as_ref(),
config_manager.replace_cloud_requirements_loader(
auth_manager.clone(),
chatgpt_base_url,
codex_home,
);
sync_default_client_residency_requirement(
&cli_overrides,
cloud_requirements.as_ref(),
)
.await;
config_manager
.sync_default_client_residency_requirement()
.await;
let auth = auth_manager.auth_cached();
let payload_v2 = AccountUpdatedNotification {
@@ -1706,14 +1650,12 @@ impl CodexMessageProcessor {
return;
}
self.auth_manager.reload();
replace_cloud_requirements_loader(
self.cloud_requirements.as_ref(),
self.config_manager.replace_cloud_requirements_loader(
self.auth_manager.clone(),
self.config.chatgpt_base_url.clone(),
self.config.codex_home.to_path_buf(),
);
let cli_overrides = self.current_cli_overrides();
sync_default_client_residency_requirement(&cli_overrides, self.cloud_requirements.as_ref())
self.config_manager
.sync_default_client_residency_requirement()
.await;
self.outgoing
@@ -2424,8 +2366,6 @@ impl CodexMessageProcessor {
personality,
);
typesafe_overrides.ephemeral = ephemeral;
let cloud_requirements = self.current_cloud_requirements();
let cli_overrides = self.current_cli_overrides();
let listener_task_context = ListenerTaskContext {
thread_manager: Arc::clone(&self.thread_manager),
thread_state_manager: self.thread_state_manager.clone(),
@@ -2438,15 +2378,11 @@ impl CodexMessageProcessor {
codex_home: self.config.codex_home.to_path_buf(),
};
let request_trace = request_context.request_trace();
let runtime_feature_enablement = self.current_runtime_feature_enablement();
let thread_config_loader = Arc::clone(&self.thread_config_loader);
let config_manager = self.config_manager.clone();
let thread_start_task = async move {
Self::thread_start_task(
listener_task_context,
cli_overrides,
runtime_feature_enablement,
cloud_requirements,
thread_config_loader,
config_manager,
request_id,
app_server_client_name,
app_server_client_version,
@@ -2520,10 +2456,7 @@ impl CodexMessageProcessor {
#[allow(clippy::too_many_arguments)]
async fn thread_start_task(
listener_task_context: ListenerTaskContext,
cli_overrides: Vec<(String, TomlValue)>,
runtime_feature_enablement: BTreeMap<String, bool>,
cloud_requirements: CloudRequirementsLoader,
thread_config_loader: Arc<dyn ThreadConfigLoader>,
config_manager: ConfigManager,
request_id: ConnectionRequestId,
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
@@ -2537,16 +2470,9 @@ impl CodexMessageProcessor {
request_trace: Option<W3cTraceContext>,
) {
let requested_cwd = typesafe_overrides.cwd.clone();
let mut config = match derive_config_from_params(
&cli_overrides,
config_overrides.clone(),
typesafe_overrides.clone(),
Arc::clone(&thread_config_loader),
&cloud_requirements,
&listener_task_context.codex_home,
&runtime_feature_enablement,
)
.await
let mut config = match config_manager
.load_with_overrides(config_overrides.clone(), typesafe_overrides.clone())
.await
{
Ok(config) => config,
Err(err) => {
@@ -2585,6 +2511,7 @@ impl CodexMessageProcessor {
let trust_target = resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &config.cwd)
.await
.unwrap_or_else(|| config.cwd.clone());
let current_cli_overrides = config_manager.current_cli_overrides();
let cli_overrides_with_trust;
let cli_overrides_for_reload = if let Err(err) =
codex_core::config::set_project_trust_level(
@@ -2606,7 +2533,7 @@ impl CodexMessageProcessor {
project_trust_key(trust_target.as_path()),
TomlValue::Table(project),
);
cli_overrides_with_trust = cli_overrides
cli_overrides_with_trust = current_cli_overrides
.iter()
.cloned()
.chain(std::iter::once((
@@ -2616,19 +2543,17 @@ impl CodexMessageProcessor {
.collect::<Vec<_>>();
cli_overrides_with_trust.as_slice()
} else {
&cli_overrides
current_cli_overrides.as_slice()
};
config = match derive_config_from_params(
cli_overrides_for_reload,
config_overrides,
typesafe_overrides,
thread_config_loader,
&cloud_requirements,
&listener_task_context.codex_home,
&runtime_feature_enablement,
)
.await
config = match config_manager
.load_with_cli_overrides(
cli_overrides_for_reload,
config_overrides,
typesafe_overrides,
/*fallback_cwd*/ None,
)
.await
{
Ok(config) => config,
Err(err) => {
@@ -4412,19 +4337,10 @@ impl CodexMessageProcessor {
.await;
// Derive a Config using the same logic as new conversation, honoring overrides if provided.
let cloud_requirements = self.current_cloud_requirements();
let cli_overrides = self.current_cli_overrides();
let runtime_feature_enablement = self.current_runtime_feature_enablement();
let config = match derive_config_for_cwd(
&cli_overrides,
request_overrides,
typesafe_overrides,
history_cwd,
&cloud_requirements,
&self.config.codex_home,
&runtime_feature_enablement,
)
.await
let config = match self
.config_manager
.load_for_cwd(request_overrides, typesafe_overrides, history_cwd)
.await
{
Ok(config) => config,
Err(err) => {
@@ -4992,19 +4908,10 @@ impl CodexMessageProcessor {
);
typesafe_overrides.ephemeral = ephemeral.then_some(true);
// Derive a Config using the same logic as new conversation, honoring overrides if provided.
let cloud_requirements = self.current_cloud_requirements();
let cli_overrides = self.current_cli_overrides();
let runtime_feature_enablement = self.current_runtime_feature_enablement();
let config = match derive_config_for_cwd(
&cli_overrides,
request_overrides,
typesafe_overrides,
history_cwd,
&cloud_requirements,
&self.config.codex_home,
&runtime_feature_enablement,
)
.await
let config = match self
.config_manager
.load_for_cwd(request_overrides, typesafe_overrides, history_cwd)
.await
{
Ok(config) => config,
Err(err) => {
@@ -6041,17 +5948,19 @@ impl CodexMessageProcessor {
params: McpServerToolCallParams,
) {
let outgoing = Arc::clone(&self.outgoing);
let (_, thread) = match self.load_thread(&params.thread_id).await {
let thread_id = params.thread_id.clone();
let (_, thread) = match self.load_thread(&thread_id).await {
Ok(thread) => thread,
Err(error) => {
self.outgoing.send_error(request_id, error).await;
return;
}
};
let meta = with_mcp_tool_call_thread_id_meta(params.meta, &thread_id);
tokio::spawn(async move {
let result = thread
.call_mcp_tool(&params.server, &params.tool, params.arguments, params.meta)
.call_mcp_tool(&params.server, &params.tool, params.arguments, meta)
.await;
match result {
Ok(result) => {
@@ -6577,8 +6486,6 @@ impl CodexMessageProcessor {
return;
}
};
let cli_overrides = self.current_cli_overrides();
let host_name = codex_config::host_name();
let mut data = Vec::new();
for cwd in cwds {
let extra_roots = extra_roots_by_cwd
@@ -6599,17 +6506,10 @@ impl CodexMessageProcessor {
continue;
}
};
let config_layer_stack = match load_config_layers_state(
LOCAL_FS.as_ref(),
&self.config.codex_home,
Some(cwd_abs.clone()),
&cli_overrides,
LoaderOverrides::default(),
CloudRequirementsLoader::default(),
self.thread_config_loader.as_ref(),
host_name.as_deref(),
)
.await
let config_layer_stack = match self
.config_manager
.load_config_layers_for_cwd(cwd_abs.clone())
.await
{
Ok(config_layer_stack) => config_layer_stack,
Err(err) => {
@@ -8765,30 +8665,25 @@ impl CodexMessageProcessor {
WindowsSandboxSetupMode::Unelevated => CoreWindowsSandboxSetupMode::Unelevated,
};
let config = Arc::clone(&self.config);
let cloud_requirements = self.current_cloud_requirements();
let config_manager = self.config_manager.clone();
let command_cwd = params
.cwd
.map(PathBuf::from)
.unwrap_or_else(|| config.cwd.to_path_buf());
let cli_overrides = self.current_cli_overrides();
let runtime_feature_enablement = self.current_runtime_feature_enablement();
let outgoing = Arc::clone(&self.outgoing);
let connection_id = request_id.connection_id;
tokio::spawn(async move {
let derived_config = derive_config_for_cwd(
&cli_overrides,
/*request_overrides*/ None,
ConfigOverrides {
cwd: Some(command_cwd.clone()),
..Default::default()
},
Some(command_cwd.clone()),
&cloud_requirements,
&config.codex_home,
&runtime_feature_enablement,
)
.await;
let derived_config = config_manager
.load_for_cwd(
/*request_overrides*/ None,
ConfigOverrides {
cwd: Some(command_cwd.clone()),
..Default::default()
},
Some(command_cwd.clone()),
)
.await;
let setup_result = match derived_config {
Ok(config) => {
let setup_request = WindowsSandboxSetupRequest {
@@ -9518,116 +9413,6 @@ fn validate_dynamic_tools(tools: &[ApiDynamicToolSpec]) -> Result<(), String> {
Ok(())
}
fn replace_cloud_requirements_loader(
cloud_requirements: &RwLock<CloudRequirementsLoader>,
auth_manager: Arc<AuthManager>,
chatgpt_base_url: String,
codex_home: PathBuf,
) {
let loader = cloud_requirements_loader(auth_manager, chatgpt_base_url, codex_home);
if let Ok(mut guard) = cloud_requirements.write() {
*guard = loader;
} else {
warn!("failed to update cloud requirements loader");
}
}
async fn sync_default_client_residency_requirement(
cli_overrides: &[(String, TomlValue)],
cloud_requirements: &RwLock<CloudRequirementsLoader>,
) {
let loader = cloud_requirements
.read()
.map(|guard| guard.clone())
.unwrap_or_default();
match codex_core::config::ConfigBuilder::default()
.cli_overrides(cli_overrides.to_vec())
.cloud_requirements(loader)
.build()
.await
{
Ok(config) => set_default_client_residency_requirement(config.enforce_residency.value()),
Err(err) => warn!(
error = %err,
"failed to sync default client residency requirement after auth refresh"
),
}
}
/// Derive the effective [`Config`] by layering three override sources.
///
/// Precedence (lowest to highest):
/// - `cli_overrides`: process-wide startup `--config` flags.
/// - `request_overrides`: per-request dotted-path overrides (`params.config`), converted JSON->TOML.
/// - `typesafe_overrides`: Request objects such as `NewThreadParams` and
/// `ThreadStartParams` support a limited set of _explicit_ config overrides, so
/// `typesafe_overrides` is a `ConfigOverrides` derived from the respective request object.
/// Because the overrides are defined explicitly in the `*Params`, this takes priority over
/// the more general "bag of config options" provided by `cli_overrides` and `request_overrides`.
async fn derive_config_from_params(
cli_overrides: &[(String, TomlValue)],
request_overrides: Option<HashMap<String, serde_json::Value>>,
typesafe_overrides: ConfigOverrides,
thread_config_loader: Arc<dyn ThreadConfigLoader>,
cloud_requirements: &CloudRequirementsLoader,
codex_home: &Path,
runtime_feature_enablement: &BTreeMap<String, bool>,
) -> std::io::Result<Config> {
let merged_cli_overrides = cli_overrides
.iter()
.cloned()
.chain(
request_overrides
.unwrap_or_default()
.into_iter()
.map(|(k, v)| (k, json_to_toml(v))),
)
.collect::<Vec<_>>();
let mut config = codex_core::config::ConfigBuilder::default()
.codex_home(codex_home.to_path_buf())
.cli_overrides(merged_cli_overrides)
.harness_overrides(typesafe_overrides)
.cloud_requirements(cloud_requirements.clone())
.thread_config_loader(thread_config_loader)
.build()
.await?;
apply_runtime_feature_enablement(&mut config, runtime_feature_enablement);
Ok(config)
}
async fn derive_config_for_cwd(
cli_overrides: &[(String, TomlValue)],
request_overrides: Option<HashMap<String, serde_json::Value>>,
typesafe_overrides: ConfigOverrides,
cwd: Option<PathBuf>,
cloud_requirements: &CloudRequirementsLoader,
codex_home: &Path,
runtime_feature_enablement: &BTreeMap<String, bool>,
) -> std::io::Result<Config> {
let merged_cli_overrides = cli_overrides
.iter()
.cloned()
.chain(
request_overrides
.unwrap_or_default()
.into_iter()
.map(|(k, v)| (k, json_to_toml(v))),
)
.collect::<Vec<_>>();
let mut config = codex_core::config::ConfigBuilder::default()
.codex_home(codex_home.to_path_buf())
.cli_overrides(merged_cli_overrides)
.harness_overrides(typesafe_overrides)
.fallback_cwd(cwd)
.cloud_requirements(cloud_requirements.clone())
.build()
.await?;
apply_runtime_feature_enablement(&mut config, runtime_feature_enablement);
Ok(config)
}
async fn read_history_cwd_from_state_db(
config: &Config,
thread_id: Option<ThreadId>,
@@ -9891,6 +9676,32 @@ fn thread_store_archive_error(operation: &str, err: ThreadStoreError) -> JSONRPC
}
}
const MCP_TOOL_THREAD_ID_META_KEY: &str = "threadId";
fn with_mcp_tool_call_thread_id_meta(
meta: Option<serde_json::Value>,
thread_id: &str,
) -> Option<serde_json::Value> {
match meta {
Some(serde_json::Value::Object(mut map)) => {
map.insert(
MCP_TOOL_THREAD_ID_META_KEY.to_string(),
serde_json::Value::String(thread_id.to_string()),
);
Some(serde_json::Value::Object(map))
}
None => {
let mut map = serde_json::Map::new();
map.insert(
MCP_TOOL_THREAD_ID_META_KEY.to_string(),
serde_json::Value::String(thread_id.to_string()),
);
Some(serde_json::Value::Object(map))
}
other => other,
}
}
fn summary_from_stored_thread(
thread: StoredThread,
fallback_provider: &str,
@@ -10523,6 +10334,8 @@ mod tests {
use codex_config::SessionThreadConfig;
use codex_config::StaticThreadConfigLoader;
use codex_config::ThreadConfigSource;
use codex_core::config_loader::CloudRequirementsLoader;
use codex_core::config_loader::LoaderOverrides;
use codex_model_provider_info::ModelProviderInfo;
use codex_model_provider_info::WireApi;
use codex_protocol::ThreadId;
@@ -10536,8 +10349,10 @@ mod tests {
use codex_utils_absolute_path::test_support::test_path_buf;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::RwLock;
use tempfile::TempDir;
#[test]
@@ -10811,21 +10626,13 @@ mod tests {
requires_openai_auth: false,
supports_websockets: true,
};
let config = derive_config_from_params(
&[],
Some(HashMap::from([
("model_provider".to_string(), json!("request")),
("features.plugins".to_string(), json!(true)),
(
"model_providers.session".to_string(),
json!({
"name": "request",
"base_url": "http://127.0.0.1:9999/api/codex",
"wire_api": "responses",
}),
),
])),
ConfigOverrides::default(),
let config_manager = ConfigManager::new(
temp_dir.path().to_path_buf(),
Arc::new(RwLock::new(Vec::new())),
Arc::new(RwLock::new(BTreeMap::new())),
LoaderOverrides::default(),
Arc::new(RwLock::new(CloudRequirementsLoader::default())),
Arg0DispatchPaths::default(),
Arc::new(StaticThreadConfigLoader::new(vec![
ThreadConfigSource::Session(SessionThreadConfig {
model_provider: Some("session".to_string()),
@@ -10836,11 +10643,24 @@ mod tests {
features: BTreeMap::from([("plugins".to_string(), false)]),
}),
])),
&CloudRequirementsLoader::default(),
temp_dir.path(),
&BTreeMap::new(),
)
.await?;
);
let config = config_manager
.load_with_overrides(
Some(HashMap::from([
("model_provider".to_string(), json!("request")),
("features.plugins".to_string(), json!(true)),
(
"model_providers.session".to_string(),
json!({
"name": "request",
"base_url": "http://127.0.0.1:9999/api/codex",
"wire_api": "responses",
}),
),
])),
ConfigOverrides::default(),
)
.await?;
assert_eq!(config.model_provider_id, "session");
assert_eq!(config.model_provider, session_provider);
@@ -1,18 +1,18 @@
use crate::config::Config;
use crate::config::ConfigBuilder;
use crate::plugins::MarketplaceAddRequest;
use crate::plugins::PluginId;
use crate::plugins::PluginInstallRequest;
use crate::plugins::PluginsManager;
use crate::plugins::add_marketplace;
use crate::plugins::configured_plugins_from_stack;
use crate::plugins::find_marketplace_manifest_path;
use crate::plugins::is_local_marketplace_source;
use crate::plugins::parse_marketplace_source;
use codex_config::types::PluginConfig;
use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
use codex_core::plugins::MarketplaceAddRequest;
use codex_core::plugins::PluginId;
use codex_core::plugins::PluginInstallRequest;
use codex_core::plugins::PluginsManager;
use codex_core::plugins::add_marketplace;
use codex_core::plugins::is_local_marketplace_source;
use codex_core_plugins::marketplace::MarketplacePluginInstallPolicy;
use codex_core_plugins::marketplace::find_marketplace_manifest_path;
use codex_protocol::protocol::Product;
use serde_json::Value as JsonValue;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::collections::HashSet;
use std::ffi::OsString;
use std::fs;
@@ -29,13 +29,13 @@ const EXTERNAL_OFFICIAL_MARKETPLACE_NAME: &str = "claude-plugins-official";
const EXTERNAL_OFFICIAL_MARKETPLACE_SOURCE: &str = "anthropics/claude-plugins-official";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExternalAgentConfigDetectOptions {
pub(crate) struct ExternalAgentConfigDetectOptions {
pub include_home: bool,
pub cwds: Option<Vec<PathBuf>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExternalAgentConfigMigrationItemType {
pub(crate) enum ExternalAgentConfigMigrationItemType {
Config,
Skills,
AgentsMd,
@@ -44,24 +44,24 @@ pub enum ExternalAgentConfigMigrationItemType {
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginsMigration {
pub(crate) struct PluginsMigration {
pub marketplace_name: String,
pub plugin_names: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MigrationDetails {
pub(crate) struct MigrationDetails {
pub plugins: Vec<PluginsMigration>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingPluginImport {
pub(crate) struct PendingPluginImport {
pub cwd: Option<PathBuf>,
pub details: MigrationDetails,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PluginImportOutcome {
pub(crate) struct PluginImportOutcome {
pub succeeded_marketplaces: Vec<String>,
pub succeeded_plugin_ids: Vec<String>,
pub failed_marketplaces: Vec<String>,
@@ -69,7 +69,7 @@ pub struct PluginImportOutcome {
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExternalAgentConfigMigrationItem {
pub(crate) struct ExternalAgentConfigMigrationItem {
pub item_type: ExternalAgentConfigMigrationItemType,
pub description: String,
pub cwd: Option<PathBuf>,
@@ -77,13 +77,13 @@ pub struct ExternalAgentConfigMigrationItem {
}
#[derive(Clone)]
pub struct ExternalAgentConfigService {
pub(crate) struct ExternalAgentConfigService {
codex_home: PathBuf,
external_agent_home: PathBuf,
}
impl ExternalAgentConfigService {
pub fn new(codex_home: PathBuf) -> Self {
pub(crate) fn new(codex_home: PathBuf) -> Self {
let external_agent_home = default_external_agent_home();
Self {
codex_home,
@@ -99,7 +99,7 @@ impl ExternalAgentConfigService {
}
}
pub async fn detect(
pub(crate) async fn detect(
&self,
params: ExternalAgentConfigDetectOptions,
) -> io::Result<Vec<ExternalAgentConfigMigrationItem>> {
@@ -119,7 +119,7 @@ impl ExternalAgentConfigService {
Ok(items)
}
pub async fn import(
pub(crate) async fn import(
&self,
migration_items: Vec<ExternalAgentConfigMigrationItem>,
) -> io::Result<Vec<PendingPluginImport>> {
@@ -297,10 +297,21 @@ impl ExternalAgentConfigService {
.await
{
Ok(config) => {
let configured_plugin_ids =
configured_plugins_from_stack(&config.config_layer_stack)
.into_keys()
.collect::<HashSet<_>>();
let configured_plugin_ids = config
.config_layer_stack
.get_user_layer()
.and_then(|user_layer| user_layer.config.get("plugins"))
.and_then(|plugins| {
match plugins.clone().try_into::<HashMap<String, PluginConfig>>() {
Ok(plugins) => Some(plugins),
Err(err) => {
tracing::warn!("invalid plugins config: {err}");
None
}
}
})
.map(|plugins| plugins.into_keys().collect::<HashSet<_>>())
.unwrap_or_default();
let configured_marketplace_plugins = configured_marketplace_plugins(
&config,
&PluginsManager::new(self.codex_home.clone()),
@@ -410,7 +421,7 @@ impl ExternalAgentConfigService {
Ok((local_details, remote_details))
}
pub async fn import_plugins(
pub(crate) async fn import_plugins(
&self,
cwd: Option<&Path>,
details: Option<MigrationDetails>,
@@ -638,7 +649,7 @@ fn extract_plugin_migration_details(
let loadable_marketplaces = collect_marketplace_import_sources(settings, source_root)
.into_iter()
.filter_map(|(marketplace_name, source)| {
parse_marketplace_source(&source.source, source.ref_name)
is_local_marketplace_source(&source.source, source.ref_name)
.ok()
.map(|_| marketplace_name)
})
+1
View File
@@ -0,0 +1 @@
pub(crate) mod external_agent_config;
+39 -134
View File
@@ -1,3 +1,5 @@
use crate::config_manager::ConfigManager;
use crate::config_manager_service::ConfigManagerError;
use crate::error_code::INTERNAL_ERROR_CODE;
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
use async_trait::async_trait;
@@ -19,11 +21,7 @@ use codex_app_server_protocol::NetworkUnixSocketPermission;
use codex_app_server_protocol::SandboxMode;
use codex_core::ThreadManager;
use codex_core::config::Config;
use codex_core::config::ConfigService;
use codex_core::config::ConfigServiceError;
use codex_core::config_loader::CloudRequirementsLoader;
use codex_core::config_loader::ConfigRequirementsToml;
use codex_core::config_loader::LoaderOverrides;
use codex_core::config_loader::ResidencyRequirement as CoreResidencyRequirement;
use codex_core::config_loader::SandboxModeRequirement as CoreSandboxModeRequirement;
use codex_core::plugins::PluginId;
@@ -34,12 +32,8 @@ use codex_features::feature_for_key;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::protocol::Op;
use serde_json::json;
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::RwLock;
use toml::Value as TomlValue;
use tracing::warn;
const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &[
@@ -72,86 +66,36 @@ impl UserConfigReloader for ThreadManager {
#[derive(Clone)]
pub(crate) struct ConfigApi {
codex_home: PathBuf,
cli_overrides: Arc<RwLock<Vec<(String, TomlValue)>>>,
runtime_feature_enablement: Arc<RwLock<BTreeMap<String, bool>>>,
loader_overrides: LoaderOverrides,
cloud_requirements: Arc<RwLock<CloudRequirementsLoader>>,
config_manager: ConfigManager,
user_config_reloader: Arc<dyn UserConfigReloader>,
analytics_events_client: AnalyticsEventsClient,
}
impl ConfigApi {
pub(crate) fn new(
codex_home: PathBuf,
cli_overrides: Arc<RwLock<Vec<(String, TomlValue)>>>,
runtime_feature_enablement: Arc<RwLock<BTreeMap<String, bool>>>,
loader_overrides: LoaderOverrides,
cloud_requirements: Arc<RwLock<CloudRequirementsLoader>>,
config_manager: ConfigManager,
user_config_reloader: Arc<dyn UserConfigReloader>,
analytics_events_client: AnalyticsEventsClient,
) -> Self {
Self {
codex_home,
cli_overrides,
runtime_feature_enablement,
loader_overrides,
cloud_requirements,
config_manager,
user_config_reloader,
analytics_events_client,
}
}
fn config_service(&self) -> ConfigService {
ConfigService::new(
self.codex_home.clone(),
self.current_cli_overrides(),
self.loader_overrides.clone(),
self.current_cloud_requirements(),
codex_config::host_name(),
)
}
fn current_cli_overrides(&self) -> Vec<(String, TomlValue)> {
self.cli_overrides
.read()
.map(|guard| guard.clone())
.unwrap_or_default()
}
fn current_runtime_feature_enablement(&self) -> BTreeMap<String, bool> {
self.runtime_feature_enablement
.read()
.map(|guard| guard.clone())
.unwrap_or_default()
}
fn current_cloud_requirements(&self) -> CloudRequirementsLoader {
self.cloud_requirements
.read()
.map(|guard| guard.clone())
.unwrap_or_default()
}
pub(crate) async fn load_latest_config(
&self,
fallback_cwd: Option<PathBuf>,
) -> Result<Config, JSONRPCErrorError> {
let mut config = codex_core::config::ConfigBuilder::default()
.codex_home(self.codex_home.clone())
.cli_overrides(self.current_cli_overrides())
.loader_overrides(self.loader_overrides.clone())
.fallback_cwd(fallback_cwd)
.cloud_requirements(self.current_cloud_requirements())
.build()
self.config_manager
.load_latest_config(fallback_cwd)
.await
.map_err(|err| JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to resolve feature override precedence: {err}"),
data: None,
})?;
apply_runtime_feature_enablement(&mut config, &self.current_runtime_feature_enablement());
Ok(config)
})
}
pub(crate) async fn read(
@@ -159,11 +103,7 @@ impl ConfigApi {
params: ConfigReadParams,
) -> Result<ConfigReadResponse, JSONRPCErrorError> {
let fallback_cwd = params.cwd.as_ref().map(PathBuf::from);
let mut response = self
.config_service()
.read(params)
.await
.map_err(map_error)?;
let mut response = self.config_manager.read(params).await.map_err(map_error)?;
let config = self.load_latest_config(fallback_cwd).await?;
for feature_key in SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT {
let Some(feature) = feature_for_key(feature_key) else {
@@ -191,7 +131,7 @@ impl ConfigApi {
&self,
) -> Result<ConfigRequirementsReadResponse, JSONRPCErrorError> {
let requirements = self
.config_service()
.config_manager
.read_requirements()
.await
.map_err(map_error)?
@@ -207,7 +147,7 @@ impl ConfigApi {
let pending_changes =
collect_plugin_enabled_candidates([(&params.key_path, &params.value)].into_iter());
let response = self
.config_service()
.config_manager
.write_value(params)
.await
.map_err(map_error)?;
@@ -227,7 +167,7 @@ impl ConfigApi {
.map(|edit| (&edit.key_path, &edit.value)),
);
let response = self
.config_service()
.config_manager
.batch_write(params)
.await
.map_err(map_error)?;
@@ -278,21 +218,17 @@ impl ConfigApi {
return Ok(ExperimentalFeatureEnablementSetResponse { enablement });
}
{
let mut runtime_feature_enablement =
self.runtime_feature_enablement
.write()
.map_err(|_| JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: "failed to update feature enablement".to_string(),
data: None,
})?;
runtime_feature_enablement.extend(
self.config_manager
.extend_runtime_feature_enablement(
enablement
.iter()
.map(|(name, enabled)| (name.clone(), *enabled)),
);
}
)
.map_err(|_| JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: "failed to update feature enablement".to_string(),
data: None,
})?;
self.load_latest_config(/*fallback_cwd*/ None).await?;
self.user_config_reloader.reload_user_config().await;
@@ -309,7 +245,8 @@ impl ConfigApi {
continue;
};
let metadata =
installed_plugin_telemetry_metadata(self.codex_home.as_path(), &plugin_id).await;
installed_plugin_telemetry_metadata(self.config_manager.codex_home(), &plugin_id)
.await;
if enabled {
self.analytics_events_client.track_plugin_enabled(metadata);
} else {
@@ -319,49 +256,6 @@ impl ConfigApi {
}
}
pub(crate) fn protected_feature_keys(
config_layer_stack: &codex_core::config_loader::ConfigLayerStack,
) -> BTreeSet<String> {
let mut protected_features = config_layer_stack
.effective_config()
.get("features")
.and_then(toml::Value::as_table)
.map(|features| features.keys().cloned().collect::<BTreeSet<_>>())
.unwrap_or_default();
if let Some(feature_requirements) = config_layer_stack
.requirements_toml()
.feature_requirements
.as_ref()
{
protected_features.extend(feature_requirements.entries.keys().cloned());
}
protected_features
}
pub(crate) fn apply_runtime_feature_enablement(
config: &mut Config,
runtime_feature_enablement: &BTreeMap<String, bool>,
) {
let protected_features = protected_feature_keys(&config.config_layer_stack);
for (name, enabled) in runtime_feature_enablement {
if protected_features.contains(name) {
continue;
}
let Some(feature) = feature_for_key(name) else {
continue;
};
if let Err(err) = config.features.set_enabled(feature, *enabled) {
warn!(
feature = name,
error = %err,
"failed to apply runtime feature enablement"
);
}
}
}
fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigRequirements {
ConfigRequirements {
allowed_approval_policies: requirements.allowed_approval_policies.map(|policies| {
@@ -495,7 +389,7 @@ fn map_network_unix_socket_permission_to_api(
}
}
fn map_error(err: ConfigServiceError) -> JSONRPCErrorError {
fn map_error(err: ConfigManagerError) -> JSONRPCErrorError {
if let Some(code) = err.write_error_code() {
return config_write_error(code, err.to_string());
}
@@ -520,7 +414,11 @@ fn config_write_error(code: ConfigWriteErrorCode, message: impl Into<String>) ->
#[cfg(test)]
mod tests {
use super::*;
use crate::config_manager::apply_runtime_feature_enablement;
use codex_analytics::AnalyticsEventsClient;
use codex_arg0::Arg0DispatchPaths;
use codex_core::config_loader::CloudRequirementsLoader;
use codex_core::config_loader::LoaderOverrides;
use codex_core::config_loader::NetworkDomainPermissionToml as CoreNetworkDomainPermissionToml;
use codex_core::config_loader::NetworkDomainPermissionsToml as CoreNetworkDomainPermissionsToml;
use codex_core::config_loader::NetworkRequirementsToml as CoreNetworkRequirementsToml;
@@ -533,9 +431,12 @@ mod tests {
use codex_protocol::protocol::AskForApproval as CoreAskForApproval;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::collections::BTreeMap;
use std::sync::RwLock;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use tempfile::TempDir;
use toml::Value as TomlValue;
#[derive(Default)]
struct RecordingUserConfigReloader {
@@ -830,11 +731,15 @@ mod tests {
);
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test"));
let config_api = ConfigApi::new(
codex_home.path().to_path_buf(),
Arc::new(RwLock::new(Vec::new())),
Arc::new(RwLock::new(BTreeMap::new())),
LoaderOverrides::default(),
Arc::new(RwLock::new(CloudRequirementsLoader::default())),
ConfigManager::new(
codex_home.path().to_path_buf(),
Arc::new(RwLock::new(Vec::new())),
Arc::new(RwLock::new(BTreeMap::new())),
LoaderOverrides::default(),
Arc::new(RwLock::new(CloudRequirementsLoader::default())),
Arg0DispatchPaths::default(),
Arc::new(codex_config::NoopThreadConfigLoader),
),
reloader.clone(),
AnalyticsEventsClient::new(
auth_manager,
+326
View File
@@ -0,0 +1,326 @@
use codex_arg0::Arg0DispatchPaths;
use codex_cloud_requirements::cloud_requirements_loader;
use codex_config::ThreadConfigLoader;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::config_loader::CloudRequirementsLoader;
use codex_core::config_loader::ConfigLayerStack;
use codex_core::config_loader::LoaderOverrides;
use codex_core::config_loader::load_config_layers_state;
use codex_exec_server::LOCAL_FS;
use codex_features::feature_for_key;
use codex_login::AuthManager;
use codex_login::default_client::set_default_client_residency_requirement;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_json_to_toml::json_to_toml;
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::RwLock;
use toml::Value as TomlValue;
use tracing::warn;
/// Shared app-server entry point for loading effective Codex configuration.
#[derive(Clone)]
pub(crate) struct ConfigManager {
codex_home: PathBuf,
cli_overrides: Arc<RwLock<Vec<(String, TomlValue)>>>,
runtime_feature_enablement: Arc<RwLock<BTreeMap<String, bool>>>,
loader_overrides: LoaderOverrides,
cloud_requirements: Arc<RwLock<CloudRequirementsLoader>>,
arg0_paths: Arg0DispatchPaths,
thread_config_loader: Arc<dyn ThreadConfigLoader>,
host_name: Option<String>,
}
impl ConfigManager {
pub(crate) fn new(
codex_home: PathBuf,
cli_overrides: Arc<RwLock<Vec<(String, TomlValue)>>>,
runtime_feature_enablement: Arc<RwLock<BTreeMap<String, bool>>>,
loader_overrides: LoaderOverrides,
cloud_requirements: Arc<RwLock<CloudRequirementsLoader>>,
arg0_paths: Arg0DispatchPaths,
thread_config_loader: Arc<dyn ThreadConfigLoader>,
) -> Self {
Self::new_with_host_name(
codex_home,
cli_overrides,
runtime_feature_enablement,
loader_overrides,
cloud_requirements,
arg0_paths,
thread_config_loader,
codex_config::host_name(),
)
}
#[allow(clippy::too_many_arguments)]
fn new_with_host_name(
codex_home: PathBuf,
cli_overrides: Arc<RwLock<Vec<(String, TomlValue)>>>,
runtime_feature_enablement: Arc<RwLock<BTreeMap<String, bool>>>,
loader_overrides: LoaderOverrides,
cloud_requirements: Arc<RwLock<CloudRequirementsLoader>>,
arg0_paths: Arg0DispatchPaths,
thread_config_loader: Arc<dyn ThreadConfigLoader>,
host_name: Option<String>,
) -> Self {
Self {
codex_home,
cli_overrides,
runtime_feature_enablement,
loader_overrides,
cloud_requirements,
arg0_paths,
thread_config_loader,
host_name,
}
}
pub(crate) fn codex_home(&self) -> &Path {
self.codex_home.as_path()
}
pub(crate) fn current_cli_overrides(&self) -> Vec<(String, TomlValue)> {
self.cli_overrides
.read()
.map(|guard| guard.clone())
.unwrap_or_default()
}
pub(crate) fn current_cloud_requirements(&self) -> CloudRequirementsLoader {
self.cloud_requirements
.read()
.map(|guard| guard.clone())
.unwrap_or_default()
}
pub(crate) fn extend_runtime_feature_enablement<I>(&self, enablement: I) -> Result<(), ()>
where
I: IntoIterator<Item = (String, bool)>,
{
let mut runtime_feature_enablement =
self.runtime_feature_enablement.write().map_err(|_| ())?;
runtime_feature_enablement.extend(enablement);
Ok(())
}
pub(crate) fn replace_cloud_requirements_loader(
&self,
auth_manager: Arc<AuthManager>,
chatgpt_base_url: String,
) {
let loader =
cloud_requirements_loader(auth_manager, chatgpt_base_url, self.codex_home.clone());
if let Ok(mut guard) = self.cloud_requirements.write() {
*guard = loader;
} else {
warn!("failed to update cloud requirements loader");
}
}
pub(crate) async fn sync_default_client_residency_requirement(&self) {
match self.load_latest_config(/*fallback_cwd*/ None).await {
Ok(config) => {
set_default_client_residency_requirement(config.enforce_residency.value());
}
Err(err) => warn!(
error = %err,
"failed to sync default client residency requirement after auth refresh"
),
}
}
pub(crate) async fn load_latest_config(
&self,
fallback_cwd: Option<PathBuf>,
) -> std::io::Result<Config> {
self.load_with_cli_overrides(
&self.current_cli_overrides(),
/*request_overrides*/ None,
ConfigOverrides::default(),
fallback_cwd,
)
.await
}
pub(crate) async fn load_with_overrides(
&self,
request_overrides: Option<HashMap<String, serde_json::Value>>,
typesafe_overrides: ConfigOverrides,
) -> std::io::Result<Config> {
self.load_with_cli_overrides(
&self.current_cli_overrides(),
request_overrides,
typesafe_overrides,
/*fallback_cwd*/ None,
)
.await
}
pub(crate) async fn load_for_cwd(
&self,
request_overrides: Option<HashMap<String, serde_json::Value>>,
typesafe_overrides: ConfigOverrides,
cwd: Option<PathBuf>,
) -> std::io::Result<Config> {
self.load_with_cli_overrides(
&self.current_cli_overrides(),
request_overrides,
typesafe_overrides,
cwd,
)
.await
}
pub(crate) async fn load_with_cli_overrides(
&self,
cli_overrides: &[(String, TomlValue)],
request_overrides: Option<HashMap<String, serde_json::Value>>,
typesafe_overrides: ConfigOverrides,
fallback_cwd: Option<PathBuf>,
) -> std::io::Result<Config> {
let merged_cli_overrides = cli_overrides
.iter()
.cloned()
.chain(
request_overrides
.unwrap_or_default()
.into_iter()
.map(|(key, value)| (key, json_to_toml(value))),
)
.collect::<Vec<_>>();
let mut config = codex_core::config::ConfigBuilder::default()
.codex_home(self.codex_home.clone())
.cli_overrides(merged_cli_overrides)
.loader_overrides(self.loader_overrides.clone())
.harness_overrides(typesafe_overrides)
.fallback_cwd(fallback_cwd)
.cloud_requirements(self.current_cloud_requirements())
.thread_config_loader(Arc::clone(&self.thread_config_loader))
.host_name(self.host_name.clone())
.build()
.await?;
self.apply_runtime_feature_enablement(&mut config);
self.apply_arg0_paths(&mut config);
Ok(config)
}
pub(crate) async fn load_config_layers_for_cwd(
&self,
cwd: AbsolutePathBuf,
) -> std::io::Result<ConfigLayerStack> {
self.load_config_layers(Some(cwd)).await
}
pub(crate) async fn load_config_layers(
&self,
cwd: Option<AbsolutePathBuf>,
) -> std::io::Result<ConfigLayerStack> {
load_config_layers_state(
LOCAL_FS.as_ref(),
&self.codex_home,
cwd,
&self.current_cli_overrides(),
self.loader_overrides.clone(),
self.current_cloud_requirements(),
self.thread_config_loader.as_ref(),
self.host_name.as_deref(),
)
.await
}
fn apply_runtime_feature_enablement(&self, config: &mut Config) {
apply_runtime_feature_enablement(config, &self.current_runtime_feature_enablement());
}
fn current_runtime_feature_enablement(&self) -> BTreeMap<String, bool> {
self.runtime_feature_enablement
.read()
.map(|guard| guard.clone())
.unwrap_or_default()
}
fn apply_arg0_paths(&self, config: &mut Config) {
config.codex_self_exe = self.arg0_paths.codex_self_exe.clone();
config.codex_linux_sandbox_exe = self.arg0_paths.codex_linux_sandbox_exe.clone();
config.main_execve_wrapper_exe = self.arg0_paths.main_execve_wrapper_exe.clone();
}
#[cfg(test)]
pub(crate) fn new_for_tests(
codex_home: PathBuf,
cli_overrides: Vec<(String, TomlValue)>,
loader_overrides: LoaderOverrides,
cloud_requirements: CloudRequirementsLoader,
host_name: Option<String>,
) -> Self {
Self::new_with_host_name(
codex_home,
Arc::new(RwLock::new(cli_overrides)),
Arc::new(RwLock::new(BTreeMap::new())),
loader_overrides,
Arc::new(RwLock::new(cloud_requirements)),
Arg0DispatchPaths::default(),
Arc::new(codex_config::NoopThreadConfigLoader),
host_name,
)
}
#[cfg(test)]
pub(crate) fn without_managed_config_for_tests(codex_home: PathBuf) -> Self {
Self::new_for_tests(
codex_home,
Vec::new(),
LoaderOverrides::without_managed_config_for_tests(),
CloudRequirementsLoader::default(),
/*host_name*/ None,
)
}
}
pub(crate) fn protected_feature_keys(config_layer_stack: &ConfigLayerStack) -> BTreeSet<String> {
let mut protected_features = config_layer_stack
.effective_config()
.get("features")
.and_then(toml::Value::as_table)
.map(|features| features.keys().cloned().collect::<BTreeSet<_>>())
.unwrap_or_default();
if let Some(feature_requirements) = config_layer_stack
.requirements_toml()
.feature_requirements
.as_ref()
{
protected_features.extend(feature_requirements.entries.keys().cloned());
}
protected_features
}
pub(crate) fn apply_runtime_feature_enablement(
config: &mut Config,
runtime_feature_enablement: &BTreeMap<String, bool>,
) {
let protected_features = protected_feature_keys(&config.config_layer_stack);
for (name, enabled) in runtime_feature_enablement {
if protected_features.contains(name) {
continue;
}
let Some(feature) = feature_for_key(name) else {
continue;
};
if let Err(err) = config.features.set_enabled(feature, *enabled) {
warn!(
feature = name,
error = %err,
"failed to apply runtime feature enablement"
);
}
}
}
@@ -1,20 +1,4 @@
use super::deserialize_config_toml_with_base;
use crate::config::edit::ConfigEdit;
use crate::config::edit::ConfigEditsBuilder;
use crate::config::managed_features::validate_explicit_feature_settings_in_config_toml;
use crate::config::managed_features::validate_feature_requirements_in_config_toml;
use crate::config_loader::CloudRequirementsLoader;
use crate::config_loader::ConfigLayerEntry;
use crate::config_loader::ConfigLayerStack;
use crate::config_loader::ConfigLayerStackOrdering;
use crate::config_loader::ConfigRequirementsToml;
use crate::config_loader::LoaderOverrides;
use crate::config_loader::load_config_layers_state;
use crate::config_loader::merge_toml_values;
use crate::path_utils;
use crate::path_utils::SymlinkWritePaths;
use crate::path_utils::resolve_symlink_write_paths;
use crate::path_utils::write_atomically;
use crate::config_manager::ConfigManager;
use codex_app_server_protocol::Config as ApiConfig;
use codex_app_server_protocol::ConfigBatchWriteParams;
use codex_app_server_protocol::ConfigLayerMetadata;
@@ -29,7 +13,19 @@ use codex_app_server_protocol::OverriddenMetadata;
use codex_app_server_protocol::WriteStatus;
use codex_config::CONFIG_TOML_FILE;
use codex_config::config_toml::ConfigToml;
use codex_exec_server::LOCAL_FS;
use codex_core::config::deserialize_config_toml_with_base;
use codex_core::config::edit::ConfigEdit;
use codex_core::config::edit::ConfigEditsBuilder;
use codex_core::config::validate_feature_requirements_for_config_toml;
use codex_core::config_loader::ConfigLayerEntry;
use codex_core::config_loader::ConfigLayerStack;
use codex_core::config_loader::ConfigLayerStackOrdering;
use codex_core::config_loader::ConfigRequirementsToml;
use codex_core::config_loader::merge_toml_values;
use codex_core::path_utils;
use codex_core::path_utils::SymlinkWritePaths;
use codex_core::path_utils::resolve_symlink_write_paths;
use codex_core::path_utils::write_atomically;
use codex_utils_absolute_path::AbsolutePathBuf;
use serde_json::Value as JsonValue;
use std::borrow::Cow;
@@ -41,7 +37,7 @@ use toml::Value as TomlValue;
use toml_edit::Item as TomlItem;
#[derive(Debug, Error)]
pub enum ConfigServiceError {
pub(crate) enum ConfigManagerError {
#[error("{message}")]
Write {
code: ConfigWriteErrorCode,
@@ -77,7 +73,7 @@ pub enum ConfigServiceError {
},
}
impl ConfigServiceError {
impl ConfigManagerError {
fn write(code: ConfigWriteErrorCode, message: impl Into<String>) -> Self {
Self::Write {
code,
@@ -101,7 +97,7 @@ impl ConfigServiceError {
Self::Anyhow { context, source }
}
pub fn write_error_code(&self) -> Option<ConfigWriteErrorCode> {
pub(crate) fn write_error_code(&self) -> Option<ConfigWriteErrorCode> {
match self {
Self::Write { code, .. } => Some(code.clone()),
_ => None,
@@ -109,78 +105,22 @@ impl ConfigServiceError {
}
}
#[derive(Clone)]
pub struct ConfigService {
codex_home: PathBuf,
cli_overrides: Vec<(String, TomlValue)>,
loader_overrides: LoaderOverrides,
cloud_requirements: CloudRequirementsLoader,
host_name: Option<String>,
}
impl ConfigService {
pub fn new(
codex_home: PathBuf,
cli_overrides: Vec<(String, TomlValue)>,
loader_overrides: LoaderOverrides,
cloud_requirements: CloudRequirementsLoader,
host_name: Option<String>,
) -> Self {
Self {
codex_home,
cli_overrides,
loader_overrides,
cloud_requirements,
host_name,
}
}
pub fn new_with_defaults(codex_home: PathBuf) -> Self {
Self {
codex_home,
cli_overrides: Vec::new(),
loader_overrides: LoaderOverrides::default(),
cloud_requirements: CloudRequirementsLoader::default(),
host_name: codex_config::host_name(),
}
}
#[cfg(test)]
pub(crate) fn without_managed_config_for_tests(codex_home: PathBuf) -> Self {
Self::new(
codex_home,
Vec::new(),
LoaderOverrides::without_managed_config_for_tests(),
CloudRequirementsLoader::default(),
/*host_name*/ None,
)
}
pub async fn read(
impl ConfigManager {
pub(crate) async fn read(
&self,
params: ConfigReadParams,
) -> Result<ConfigReadResponse, ConfigServiceError> {
) -> Result<ConfigReadResponse, ConfigManagerError> {
let layers = match params.cwd.as_deref() {
Some(cwd) => {
let cwd = AbsolutePathBuf::try_from(PathBuf::from(cwd)).map_err(|err| {
ConfigServiceError::io("failed to resolve config cwd to an absolute path", err)
ConfigManagerError::io("failed to resolve config cwd to an absolute path", err)
})?;
crate::config::ConfigBuilder::default()
.codex_home(self.codex_home.clone())
.cli_overrides(self.cli_overrides.clone())
.loader_overrides(self.loader_overrides.clone())
.fallback_cwd(Some(cwd.to_path_buf()))
.cloud_requirements(self.cloud_requirements.clone())
.host_name(self.host_name.clone())
.build()
.await
.map_err(|err| {
ConfigServiceError::io("failed to read configuration layers", err)
})?
.config_layer_stack
self.load_config_layers(Some(cwd)).await.map_err(|err| {
ConfigManagerError::io("failed to read configuration layers", err)
})?
}
None => self.load_thread_agnostic_config().await.map_err(|err| {
ConfigServiceError::io("failed to read configuration layers", err)
ConfigManagerError::io("failed to read configuration layers", err)
})?,
};
@@ -188,12 +128,12 @@ impl ConfigService {
let effective_config_toml: ConfigToml = effective
.try_into()
.map_err(|err| ConfigServiceError::toml("invalid configuration", err))?;
.map_err(|err| ConfigManagerError::toml("invalid configuration", err))?;
let json_value = serde_json::to_value(&effective_config_toml)
.map_err(|err| ConfigServiceError::json("failed to serialize configuration", err))?;
.map_err(|err| ConfigManagerError::json("failed to serialize configuration", err))?;
let config: ApiConfig = serde_json::from_value(json_value)
.map_err(|err| ConfigServiceError::json("failed to deserialize configuration", err))?;
.map_err(|err| ConfigManagerError::json("failed to deserialize configuration", err))?;
Ok(ConfigReadResponse {
config,
@@ -211,13 +151,13 @@ impl ConfigService {
})
}
pub async fn read_requirements(
pub(crate) async fn read_requirements(
&self,
) -> Result<Option<ConfigRequirementsToml>, ConfigServiceError> {
) -> Result<Option<ConfigRequirementsToml>, ConfigManagerError> {
let layers = self
.load_thread_agnostic_config()
.await
.map_err(|err| ConfigServiceError::io("failed to read configuration layers", err))?;
.map_err(|err| ConfigManagerError::io("failed to read configuration layers", err))?;
let requirements = layers.requirements_toml().clone();
if requirements.is_empty() {
@@ -227,19 +167,19 @@ impl ConfigService {
}
}
pub async fn write_value(
pub(crate) async fn write_value(
&self,
params: ConfigValueWriteParams,
) -> Result<ConfigWriteResponse, ConfigServiceError> {
) -> Result<ConfigWriteResponse, ConfigManagerError> {
let edits = vec![(params.key_path, params.value, params.merge_strategy)];
self.apply_edits(params.file_path, params.expected_version, edits)
.await
}
pub async fn batch_write(
pub(crate) async fn batch_write(
&self,
params: ConfigBatchWriteParams,
) -> Result<ConfigWriteResponse, ConfigServiceError> {
) -> Result<ConfigWriteResponse, ConfigManagerError> {
let edits = params
.edits
.into_iter()
@@ -250,37 +190,22 @@ impl ConfigService {
.await
}
pub async fn load_user_saved_config(
&self,
) -> Result<codex_app_server_protocol::UserSavedConfig, ConfigServiceError> {
let layers = self
.load_thread_agnostic_config()
.await
.map_err(|err| ConfigServiceError::io("failed to load configuration", err))?;
let toml_value = layers.effective_config();
let cfg: ConfigToml = toml_value
.try_into()
.map_err(|err| ConfigServiceError::toml("failed to parse config.toml", err))?;
Ok(cfg.into())
}
async fn apply_edits(
&self,
file_path: Option<String>,
expected_version: Option<String>,
edits: Vec<(String, JsonValue, MergeStrategy)>,
) -> Result<ConfigWriteResponse, ConfigServiceError> {
) -> Result<ConfigWriteResponse, ConfigManagerError> {
let allowed_path =
AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, &self.codex_home);
AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, self.codex_home());
let provided_path = match file_path {
Some(path) => AbsolutePathBuf::from_absolute_path(PathBuf::from(path))
.map_err(|err| ConfigServiceError::io("failed to resolve user config path", err))?,
.map_err(|err| ConfigManagerError::io("failed to resolve user config path", err))?,
None => allowed_path.clone(),
};
if !paths_match(&allowed_path, &provided_path) {
return Err(ConfigServiceError::write(
return Err(ConfigManagerError::write(
ConfigWriteErrorCode::ConfigLayerReadonly,
"Only writes to the user config are allowed",
));
@@ -289,7 +214,7 @@ impl ConfigService {
let layers = self
.load_thread_agnostic_config()
.await
.map_err(|err| ConfigServiceError::io("failed to load configuration", err))?;
.map_err(|err| ConfigManagerError::io("failed to load configuration", err))?;
let user_layer = match layers.get_user_layer() {
Some(layer) => Cow::Borrowed(layer),
None => Cow::Owned(create_empty_user_layer(&allowed_path).await?),
@@ -298,7 +223,7 @@ impl ConfigService {
if let Some(expected) = expected_version.as_deref()
&& expected != user_layer.version
{
return Err(ConfigServiceError::write(
return Err(ConfigManagerError::write(
ConfigWriteErrorCode::ConfigVersionConflict,
"Configuration was modified since last read. Fetch latest version and retry.",
));
@@ -310,20 +235,20 @@ impl ConfigService {
for (key_path, value, strategy) in edits.into_iter() {
let segments = parse_key_path(&key_path).map_err(|message| {
ConfigServiceError::write(ConfigWriteErrorCode::ConfigValidationError, message)
ConfigManagerError::write(ConfigWriteErrorCode::ConfigValidationError, message)
})?;
let original_value = value_at_path(&user_config, &segments).cloned();
let parsed_value = parse_value(value).map_err(|message| {
ConfigServiceError::write(ConfigWriteErrorCode::ConfigValidationError, message)
ConfigManagerError::write(ConfigWriteErrorCode::ConfigValidationError, message)
})?;
apply_merge(&mut user_config, &segments, parsed_value.as_ref(), strategy).map_err(
|err| match err {
MergeError::PathNotFound => ConfigServiceError::write(
MergeError::PathNotFound => ConfigManagerError::write(
ConfigWriteErrorCode::ConfigPathNotFound,
"Path not found",
),
MergeError::Validation(message) => ConfigServiceError::write(
MergeError::Validation(message) => ConfigManagerError::write(
ConfigWriteErrorCode::ConfigValidationError,
message,
),
@@ -336,7 +261,7 @@ impl ConfigService {
Some(value) => ConfigEdit::SetPath {
segments: segments.clone(),
value: toml_value_to_item(&value).map_err(|err| {
ConfigServiceError::anyhow("failed to build config edits", err)
ConfigManagerError::anyhow("failed to build config edits", err)
})?,
},
None => ConfigEdit::ClearPath {
@@ -350,56 +275,45 @@ impl ConfigService {
}
validate_config(&user_config).map_err(|err| {
ConfigServiceError::write(
ConfigManagerError::write(
ConfigWriteErrorCode::ConfigValidationError,
format!("Invalid configuration: {err}"),
)
})?;
let user_config_toml =
deserialize_config_toml_with_base(user_config.clone(), &self.codex_home).map_err(
deserialize_config_toml_with_base(user_config.clone(), self.codex_home()).map_err(
|err| {
ConfigServiceError::write(
ConfigManagerError::write(
ConfigWriteErrorCode::ConfigValidationError,
format!("Invalid configuration: {err}"),
)
},
)?;
validate_explicit_feature_settings_in_config_toml(
validate_feature_requirements_for_config_toml(
&user_config_toml,
layers.requirements().feature_requirements.as_ref(),
)
.map_err(|err| {
ConfigServiceError::write(
ConfigManagerError::write(
ConfigWriteErrorCode::ConfigValidationError,
format!("Invalid configuration: {err}"),
)
})?;
validate_feature_requirements_in_config_toml(
&user_config_toml,
layers.requirements().feature_requirements.as_ref(),
)
.map_err(|err| {
ConfigServiceError::write(
ConfigWriteErrorCode::ConfigValidationError,
format!("Invalid configuration: {err}"),
)
})?;
let updated_layers = layers.with_user_config(&provided_path, user_config.clone());
let effective = updated_layers.effective_config();
validate_config(&effective).map_err(|err| {
ConfigServiceError::write(
ConfigManagerError::write(
ConfigWriteErrorCode::ConfigValidationError,
format!("Invalid configuration: {err}"),
)
})?;
if !config_edits.is_empty() {
ConfigEditsBuilder::new(&self.codex_home)
ConfigEditsBuilder::new(self.codex_home())
.with_edits(config_edits)
.apply()
.await
.map_err(|err| ConfigServiceError::anyhow("failed to persist config.toml", err))?;
.map_err(|err| ConfigManagerError::anyhow("failed to persist config.toml", err))?;
}
let overridden = first_overridden_edit(&updated_layers, &effective, &parsed_segments);
@@ -413,7 +327,7 @@ impl ConfigService {
version: updated_layers
.get_user_layer()
.ok_or_else(|| {
ConfigServiceError::write(
ConfigManagerError::write(
ConfigWriteErrorCode::UserLayerNotFound,
"user layer not found in updated layers",
)
@@ -429,40 +343,29 @@ impl ConfigService {
/// include any in-repo .codex/ folders because there is no cwd/project root
/// associated with this query.
async fn load_thread_agnostic_config(&self) -> std::io::Result<ConfigLayerStack> {
let cwd: Option<AbsolutePathBuf> = None;
load_config_layers_state(
LOCAL_FS.as_ref(),
&self.codex_home,
cwd,
&self.cli_overrides,
self.loader_overrides.clone(),
self.cloud_requirements.clone(),
&codex_config::NoopThreadConfigLoader,
self.host_name.as_deref(),
)
.await
self.load_config_layers(/*cwd*/ None).await
}
}
async fn create_empty_user_layer(
config_toml: &AbsolutePathBuf,
) -> Result<ConfigLayerEntry, ConfigServiceError> {
) -> Result<ConfigLayerEntry, ConfigManagerError> {
let SymlinkWritePaths {
read_path,
write_path,
} = resolve_symlink_write_paths(config_toml.as_path())
.map_err(|err| ConfigServiceError::io("failed to resolve user config path", err))?;
.map_err(|err| ConfigManagerError::io("failed to resolve user config path", err))?;
let toml_value = match read_path {
Some(path) => match tokio::fs::read_to_string(&path).await {
Ok(contents) => toml::from_str(&contents).map_err(|e| {
ConfigServiceError::toml("failed to parse existing user config.toml", e)
ConfigManagerError::toml("failed to parse existing user config.toml", e)
})?,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
write_empty_user_config(write_path.clone()).await?;
TomlValue::Table(toml::map::Map::new())
}
Err(err) => {
return Err(ConfigServiceError::io(
return Err(ConfigManagerError::io(
"failed to read user config.toml",
err,
));
@@ -481,11 +384,11 @@ async fn create_empty_user_layer(
))
}
async fn write_empty_user_config(write_path: PathBuf) -> Result<(), ConfigServiceError> {
async fn write_empty_user_config(write_path: PathBuf) -> Result<(), ConfigManagerError> {
task::spawn_blocking(move || write_atomically(&write_path, ""))
.await
.map_err(|err| ConfigServiceError::anyhow("config persistence task panicked", err.into()))?
.map_err(|err| ConfigServiceError::io("failed to create empty user config.toml", err))
.map_err(|err| ConfigManagerError::anyhow("config persistence task panicked", err.into()))?
.map_err(|err| ConfigManagerError::io("failed to create empty user config.toml", err))
}
fn parse_value(value: JsonValue) -> Result<Option<TomlValue>, String> {
@@ -746,5 +649,5 @@ fn find_effective_layer(
}
#[cfg(test)]
#[path = "service_tests.rs"]
#[path = "config_manager_service_tests.rs"]
mod tests;
@@ -4,6 +4,9 @@ use codex_app_server_protocol::AppConfig;
use codex_app_server_protocol::AppToolApproval;
use codex_app_server_protocol::AppsConfig;
use codex_app_server_protocol::AskForApproval;
use codex_core::config_loader::CloudRequirementsLoader;
use codex_core::config_loader::FeatureRequirementsToml;
use codex_core::config_loader::LoaderOverrides;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
@@ -74,7 +77,7 @@ unified_exec = true
"#;
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), original)?;
let service = ConfigService::without_managed_config_for_tests(tmp.path().to_path_buf());
let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf());
service
.write_value(ConfigValueWriteParams {
file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()),
@@ -108,7 +111,7 @@ async fn write_value_supports_nested_app_paths() -> Result<()> {
let tmp = tempdir().expect("tempdir");
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "")?;
let service = ConfigService::without_managed_config_for_tests(tmp.path().to_path_buf());
let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf());
service
.write_value(ConfigValueWriteParams {
file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()),
@@ -172,7 +175,7 @@ async fn write_value_supports_custom_mcp_server_default_tool_approval_mode() ->
"[mcp_servers.docs]\ncommand = \"docs-server\"\n",
)?;
let service = ConfigService::without_managed_config_for_tests(tmp.path().to_path_buf());
let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf());
service
.write_value(ConfigValueWriteParams {
file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()),
@@ -218,7 +221,7 @@ async fn read_includes_origins_and_layers() {
std::fs::write(&managed_path, "approval_policy = \"never\"").unwrap();
let managed_file = AbsolutePathBuf::try_from(managed_path.clone()).expect("managed file");
let service = ConfigService::new(
let service = ConfigManager::new_for_tests(
tmp.path().to_path_buf(),
vec![],
LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()),
@@ -297,7 +300,7 @@ writable_roots = ["~/code"]
),
);
let service = ConfigService::new(
let service = ConfigManager::new_for_tests(
tmp.path().to_path_buf(),
vec![],
loader_overrides,
@@ -338,7 +341,7 @@ async fn write_value_reports_override() {
std::fs::write(&managed_path, "approval_policy = \"never\"").unwrap();
let managed_file = AbsolutePathBuf::try_from(managed_path.clone()).expect("managed file");
let service = ConfigService::new(
let service = ConfigManager::new_for_tests(
tmp.path().to_path_buf(),
vec![],
LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()),
@@ -388,7 +391,7 @@ async fn version_conflict_rejected() {
let user_path = tmp.path().join(CONFIG_TOML_FILE);
std::fs::write(&user_path, "model = \"user\"").unwrap();
let service = ConfigService::without_managed_config_for_tests(tmp.path().to_path_buf());
let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf());
let error = service
.write_value(ConfigValueWriteParams {
file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()),
@@ -411,7 +414,7 @@ async fn write_value_defaults_to_user_config_path() {
let tmp = tempdir().expect("tempdir");
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "").unwrap();
let service = ConfigService::without_managed_config_for_tests(tmp.path().to_path_buf());
let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf());
service
.write_value(ConfigValueWriteParams {
file_path: None,
@@ -438,7 +441,7 @@ async fn invalid_user_value_rejected_even_if_overridden_by_managed() {
let managed_path = tmp.path().join("managed_config.toml");
std::fs::write(&managed_path, "approval_policy = \"never\"").unwrap();
let service = ConfigService::new(
let service = ConfigManager::new_for_tests(
tmp.path().to_path_buf(),
vec![],
LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()),
@@ -471,7 +474,7 @@ async fn reserved_builtin_provider_override_rejected() {
let tmp = tempdir().expect("tempdir");
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "model = \"user\"\n").unwrap();
let service = ConfigService::without_managed_config_for_tests(tmp.path().to_path_buf());
let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf());
let error = service
.write_value(ConfigValueWriteParams {
file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()),
@@ -499,13 +502,13 @@ async fn write_value_rejects_feature_requirement_conflict() {
let tmp = tempdir().expect("tempdir");
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "").unwrap();
let service = ConfigService::new(
let service = ConfigManager::new_for_tests(
tmp.path().to_path_buf(),
vec![],
LoaderOverrides::without_managed_config_for_tests(),
CloudRequirementsLoader::new(async {
Ok(Some(ConfigRequirementsToml {
feature_requirements: Some(crate::config_loader::FeatureRequirementsToml {
feature_requirements: Some(FeatureRequirementsToml {
entries: BTreeMap::from([("personality".to_string(), true)]),
}),
..Default::default()
@@ -546,13 +549,13 @@ async fn write_value_rejects_profile_feature_requirement_conflict() {
let tmp = tempdir().expect("tempdir");
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "").unwrap();
let service = ConfigService::new(
let service = ConfigManager::new_for_tests(
tmp.path().to_path_buf(),
vec![],
LoaderOverrides::without_managed_config_for_tests(),
CloudRequirementsLoader::new(async {
Ok(Some(ConfigRequirementsToml {
feature_requirements: Some(crate::config_loader::FeatureRequirementsToml {
feature_requirements: Some(FeatureRequirementsToml {
entries: BTreeMap::from([("personality".to_string(), true)]),
}),
..Default::default()
@@ -604,7 +607,7 @@ async fn read_reports_managed_overrides_user_and_session_flags() {
TomlValue::String("session".to_string()),
)];
let service = ConfigService::new(
let service = ConfigManager::new_for_tests(
tmp.path().to_path_buf(),
cli_overrides,
LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()),
@@ -658,7 +661,7 @@ async fn write_value_reports_managed_override() {
std::fs::write(&managed_path, "approval_policy = \"never\"").unwrap();
let managed_file = AbsolutePathBuf::try_from(managed_path.clone()).expect("managed file");
let service = ConfigService::new(
let service = ConfigManager::new_for_tests(
tmp.path().to_path_buf(),
vec![],
LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()),
@@ -714,7 +717,7 @@ alpha = "a"
std::fs::write(&path, base)?;
let service = ConfigService::without_managed_config_for_tests(tmp.path().to_path_buf());
let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf());
service
.write_value(ConfigValueWriteParams {
file_path: Some(path.display().to_string()),
@@ -1,3 +1,8 @@
use crate::config::external_agent_config::ExternalAgentConfigDetectOptions;
use crate::config::external_agent_config::ExternalAgentConfigMigrationItem as CoreMigrationItem;
use crate::config::external_agent_config::ExternalAgentConfigMigrationItemType as CoreMigrationItemType;
use crate::config::external_agent_config::ExternalAgentConfigService;
use crate::config::external_agent_config::PendingPluginImport;
use crate::error_code::INTERNAL_ERROR_CODE;
use codex_app_server_protocol::ExternalAgentConfigDetectParams;
use codex_app_server_protocol::ExternalAgentConfigDetectResponse;
@@ -7,11 +12,6 @@ use codex_app_server_protocol::ExternalAgentConfigMigrationItemType;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::MigrationDetails;
use codex_app_server_protocol::PluginsMigration;
use codex_core::external_agent_config::ExternalAgentConfigDetectOptions;
use codex_core::external_agent_config::ExternalAgentConfigMigrationItem as CoreMigrationItem;
use codex_core::external_agent_config::ExternalAgentConfigMigrationItemType as CoreMigrationItemType;
use codex_core::external_agent_config::ExternalAgentConfigService;
use codex_core::external_agent_config::PendingPluginImport;
use std::io;
use std::path::PathBuf;
@@ -108,12 +108,12 @@ impl ExternalAgentConfigApi {
description: migration_item.description,
cwd: migration_item.cwd,
details: migration_item.details.map(|details| {
codex_core::external_agent_config::MigrationDetails {
crate::config::external_agent_config::MigrationDetails {
plugins: details
.plugins
.into_iter()
.map(|plugin| {
codex_core::external_agent_config::PluginsMigration {
crate::config::external_agent_config::PluginsMigration {
marketplace_name: plugin.marketplace_name,
plugin_names: plugin.plugin_names,
}
+3
View File
@@ -70,7 +70,10 @@ mod app_server_tracing;
mod bespoke_event_handling;
mod codex_message_processor;
mod command_exec;
mod config;
mod config_api;
mod config_manager;
mod config_manager_service;
mod dynamic_tools;
mod error_code;
mod external_agent_config_api;
+12 -9
View File
@@ -10,6 +10,7 @@ use std::sync::atomic::Ordering;
use crate::codex_message_processor::CodexMessageProcessor;
use crate::codex_message_processor::CodexMessageProcessorArgs;
use crate::config_api::ConfigApi;
use crate::config_manager::ConfigManager;
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
use crate::external_agent_config_api::ExternalAgentConfigApi;
use crate::fs_api::FsApi;
@@ -294,6 +295,15 @@ impl MessageProcessor {
let cli_overrides = Arc::new(RwLock::new(cli_overrides));
let runtime_feature_enablement = Arc::new(RwLock::new(BTreeMap::new()));
let cloud_requirements = Arc::new(RwLock::new(cloud_requirements));
let config_manager = ConfigManager::new(
config.codex_home.to_path_buf(),
cli_overrides,
runtime_feature_enablement,
loader_overrides,
cloud_requirements,
arg0_paths.clone(),
thread_config_loader,
);
let codex_message_processor = CodexMessageProcessor::new(CodexMessageProcessorArgs {
auth_manager: auth_manager.clone(),
thread_manager: Arc::clone(&thread_manager),
@@ -301,10 +311,7 @@ impl MessageProcessor {
analytics_events_client: analytics_events_client.clone(),
arg0_paths,
config: Arc::clone(&config),
cli_overrides: cli_overrides.clone(),
runtime_feature_enablement: runtime_feature_enablement.clone(),
cloud_requirements: cloud_requirements.clone(),
thread_config_loader,
config_manager: config_manager.clone(),
feedback,
log_db,
});
@@ -314,11 +321,7 @@ impl MessageProcessor {
.plugins_manager()
.maybe_start_plugin_startup_tasks_for_config(&config, auth_manager.clone());
let config_api = ConfigApi::new(
config.codex_home.to_path_buf(),
cli_overrides,
runtime_feature_enablement,
loader_overrides,
cloud_requirements,
config_manager,
thread_manager.clone(),
analytics_events_client.clone(),
);
@@ -15,6 +15,7 @@ use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_core::config::ConfigBuilder;
use codex_core::config_loader::LoaderOverrides;
use codex_features::FEATURES;
use codex_features::Stage;
use pretty_assertions::assert_eq;
@@ -32,6 +33,9 @@ async fn experimental_feature_list_returns_feature_metadata_with_stage() -> Resu
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.loader_overrides(LoaderOverrides::with_managed_config_path_for_tests(
codex_home.path().join("managed_config.toml"),
))
.build()
.await?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
+11 -2
View File
@@ -83,10 +83,11 @@ url = "{mcp_server_url}/mcp"
)
.await??;
let ThreadStartResponse { thread, .. } = to_response(thread_start_resp)?;
let thread_id = thread.id.clone();
let tool_call_request_id = mcp
.send_mcp_server_tool_call_request(McpServerToolCallParams {
thread_id: thread.id,
thread_id: thread_id.clone(),
server: TEST_SERVER_NAME.to_string(),
tool: TEST_TOOL_NAME.to_string(),
arguments: Some(json!({
@@ -114,6 +115,7 @@ url = "{mcp_server_url}/mcp"
response.structured_content,
Some(json!({
"echoed": "hello from app",
"threadId": thread_id,
}))
);
assert_eq!(response.is_error, Some(false));
@@ -203,7 +205,7 @@ impl ServerHandler for ToolAppsMcpServer {
async fn call_tool(
&self,
request: CallToolRequestParams,
_context: RequestContext<RoleServer>,
context: RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> {
assert_eq!(request.name.as_ref(), TEST_TOOL_NAME);
let message = request
@@ -212,12 +214,19 @@ impl ServerHandler for ToolAppsMcpServer {
.and_then(|arguments| arguments.get("message"))
.and_then(|value| value.as_str())
.unwrap_or_default();
let thread_id = context
.meta
.0
.get("threadId")
.and_then(|value| value.as_str())
.unwrap_or_default();
let mut meta = Meta::new();
meta.0.insert("calledBy".to_string(), json!("mcp-app"));
let mut result = CallToolResult::structured(json!({
"echoed": message,
"threadId": thread_id,
}));
result.content = vec![Content::text(format!("echo: {message}"))];
result.meta = Some(meta);
@@ -76,6 +76,7 @@ async fn request_permissions_round_trip() -> Result<()> {
assert_eq!(params.thread_id, thread.id);
assert_eq!(params.turn_id, turn.id);
assert_eq!(params.item_id, "call1");
assert!(params.cwd.as_path().is_absolute());
assert_eq!(params.reason, Some("Select a workspace root".to_string()));
let requested_writes = params
.permissions
+2 -1
View File
@@ -105,7 +105,7 @@ INSERT INTO jobs (
let memory_root = codex_home.path().join("memories");
std::fs::create_dir_all(&memory_root)?;
std::fs::write(memory_root.join("memory_summary.md"), "stale memory")?;
drop(pool);
pool.close().await;
let mut cmd = codex_command(codex_home.path())?;
cmd.args(["debug", "clear-memories"])
@@ -127,6 +127,7 @@ INSERT INTO jobs (
assert_eq!(memory_jobs_count, 0);
assert!(memory_root.exists());
assert_eq!(std::fs::read_dir(memory_root)?.count(), 0);
pool.close().await;
Ok(())
}
+2 -2
View File
@@ -22,8 +22,8 @@ pub use model::SkillLoadOutcome;
pub use model::SkillMetadata;
pub use model::SkillPolicy;
pub use model::filter_skill_load_outcome_for_product;
pub use render::RenderedSkillsSection;
pub use render::AvailableSkills;
pub use render::SkillMetadataBudget;
pub use render::SkillRenderReport;
pub use render::build_available_skills;
pub use render::default_skill_metadata_budget;
pub use render::render_skills_section;
+16 -47
View File
@@ -3,8 +3,6 @@ use codex_otel::SessionTelemetry;
use codex_otel::THREAD_SKILLS_ENABLED_TOTAL_METRIC;
use codex_otel::THREAD_SKILLS_KEPT_TOTAL_METRIC;
use codex_otel::THREAD_SKILLS_TRUNCATED_METRIC;
use codex_protocol::protocol::SKILLS_INSTRUCTIONS_CLOSE_TAG;
use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG;
use codex_protocol::protocol::SkillScope;
use codex_utils_output_truncation::approx_token_count;
@@ -48,8 +46,8 @@ pub enum SkillRenderSideEffects<'a> {
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenderedSkillsSection {
pub text: String,
pub struct AvailableSkills {
pub skill_lines: Vec<String>,
pub report: SkillRenderReport,
pub emit_warning: bool,
}
@@ -71,11 +69,11 @@ pub fn default_skill_metadata_budget(context_window: Option<i64>) -> SkillMetada
))
}
pub fn render_skills_section(
pub fn build_available_skills(
skills: &[SkillMetadata],
budget: SkillMetadataBudget,
side_effects: SkillRenderSideEffects<'_>,
) -> Option<RenderedSkillsSection> {
) -> Option<AvailableSkills> {
if skills.is_empty() {
let _ = record_skill_render_side_effects(
side_effects,
@@ -93,39 +91,8 @@ pub fn render_skills_section(
report.included_count,
report.omitted_count > 0,
);
let mut lines: Vec<String> = Vec::new();
lines.push("## Skills".to_string());
lines.push("A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and file path so you can open the source for full instructions when using a specific skill.".to_string());
lines.push("### Available skills".to_string());
if !skill_lines.is_empty() {
lines.extend(skill_lines);
}
lines.push("### How to use skills".to_string());
lines.push(
r###"- Discovery: The list above is the skills available in this session (name + description + file path). Skill bodies live on disk at the listed paths.
- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description shown above, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.
- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.
- How to use a skill (progressive disclosure):
1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow.
2) When `SKILL.md` references relative paths (e.g., `scripts/foo.py`), resolve them relative to the skill directory listed above first, and only consider other paths if needed.
3) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything.
4) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.
5) If `assets/` or templates exist, reuse them instead of recreating from scratch.
- Coordination and sequencing:
- If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.
- Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.
- Context hygiene:
- Keep context small: summarize long sections instead of pasting them; only load extra files when needed.
- Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked.
- When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice.
- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue."###
.to_string(),
);
let body = lines.join("\n");
Some(RenderedSkillsSection {
text: format!("{SKILLS_INSTRUCTIONS_OPEN_TAG}\n{body}\n{SKILLS_INSTRUCTIONS_CLOSE_TAG}"),
Some(AvailableSkills {
skill_lines,
report,
emit_warning,
})
@@ -274,7 +241,7 @@ mod tests {
.cost(&format!("{}\n", render_skill_line(&admin)));
let budget = SkillMetadataBudget::Characters(system_cost + admin_cost);
let rendered = render_skills_section(
let rendered = build_available_skills(
&[system, user, repo, admin],
budget,
SkillRenderSideEffects::None,
@@ -284,10 +251,11 @@ mod tests {
assert_eq!(rendered.report.included_count, 2);
assert_eq!(rendered.report.omitted_count, 2);
assert!(!rendered.emit_warning);
assert!(rendered.text.contains("- system-skill:"));
assert!(rendered.text.contains("- admin-skill:"));
assert!(!rendered.text.contains("- repo-skill:"));
assert!(!rendered.text.contains("- user-skill:"));
let rendered_text = rendered.skill_lines.join("\n");
assert!(rendered_text.contains("- system-skill:"));
assert!(rendered_text.contains("- admin-skill:"));
assert!(!rendered_text.contains("- repo-skill:"));
assert!(!rendered_text.contains("- user-skill:"));
}
#[test]
@@ -300,13 +268,14 @@ mod tests {
let budget = SkillMetadataBudget::Characters(repo_cost);
let rendered =
render_skills_section(&[oversized, repo], budget, SkillRenderSideEffects::None)
build_available_skills(&[oversized, repo], budget, SkillRenderSideEffects::None)
.expect("skills render");
assert_eq!(rendered.report.included_count, 1);
assert_eq!(rendered.report.omitted_count, 1);
assert!(!rendered.emit_warning);
assert!(!rendered.text.contains("- oversized-system-skill:"));
assert!(rendered.text.contains("- repo-skill:"));
let rendered_text = rendered.skill_lines.join("\n");
assert!(!rendered_text.contains("- oversized-system-skill:"));
assert!(rendered_text.contains("- repo-skill:"));
}
}
+1 -1
View File
@@ -104,7 +104,6 @@ sha2 = { workspace = true }
shlex = { workspace = true }
similar = { workspace = true }
tempfile = { workspace = true }
test-log = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true, features = [
"io-std",
@@ -166,6 +165,7 @@ opentelemetry_sdk = { workspace = true, features = [
] }
serial_test = { workspace = true }
tempfile = { workspace = true }
test-log = { workspace = true }
tracing-opentelemetry = { workspace = true }
tracing-subscriber = { workspace = true }
tracing-test = { workspace = true, features = ["no-env-filter"] }
+1 -2
View File
@@ -1,3 +1,2 @@
#[cfg(test)]
mod render;
pub(crate) use render::render_apps_section;
+3 -14
View File
@@ -1,22 +1,11 @@
use crate::context::AppsInstructions;
use crate::context::ContextualUserFragment;
use codex_app_server_protocol::AppInfo;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_protocol::protocol::APPS_INSTRUCTIONS_CLOSE_TAG;
use codex_protocol::protocol::APPS_INSTRUCTIONS_OPEN_TAG;
pub(crate) fn render_apps_section(connectors: &[AppInfo]) -> Option<String> {
if !connectors
.iter()
.any(|connector| connector.is_accessible && connector.is_enabled)
{
return None;
}
let body = format!(
"## Apps (Connectors)\nApps (Connectors) can be explicitly triggered in user messages in the format `[$app-name](app://{{connector_id}})`. Apps can also be implicitly triggered as long as the context suggests usage of available apps.\nAn app is equivalent to a set of MCP tools within the `{CODEX_APPS_MCP_SERVER_NAME}` MCP.\nAn installed app's MCP tools are either provided to you already, or can be lazy-loaded through the `tool_search` tool. If `tool_search` is available, the apps that are searchable by `tools_search` will be listed by it.\nDo not additionally call list_mcp_resources or list_mcp_resource_templates for apps."
);
Some(format!(
"{APPS_INSTRUCTIONS_OPEN_TAG}\n{body}\n{APPS_INSTRUCTIONS_CLOSE_TAG}"
))
AppsInstructions::from_connectors(connectors).map(|instructions| instructions.render())
}
#[cfg(test)]
+8 -2
View File
@@ -741,8 +741,14 @@ async fn handle_request_permissions(
reason: event.reason,
permissions: event.permissions,
};
let response_fut =
parent_session.request_permissions(parent_ctx, call_id.clone(), args, cancel_token.clone());
let cwd = event.cwd.unwrap_or_else(|| parent_ctx.cwd.clone());
let response_fut = parent_session.request_permissions_for_cwd(
parent_ctx,
call_id.clone(),
args,
cwd,
cancel_token.clone(),
);
let response =
await_request_permissions_with_cancel(response_fut, parent_session, &call_id, cancel_token)
.await;
@@ -180,8 +180,10 @@ async fn handle_request_permissions_uses_tool_call_id_for_round_trip() {
},
scope: PermissionGrantScope::Turn,
};
let delegated_cwd = parent_ctx.cwd.join("delegated-cwd");
let cancel_token = CancellationToken::new();
let request_call_id = call_id.clone();
let request_cwd = delegated_cwd.clone();
let handle = tokio::spawn({
let codex = Arc::clone(&codex);
@@ -203,6 +205,7 @@ async fn handle_request_permissions_uses_tool_call_id_for_round_trip() {
}),
..RequestPermissionProfile::default()
},
cwd: Some(request_cwd),
},
&cancel_token,
)
@@ -218,6 +221,7 @@ async fn handle_request_permissions_uses_tool_call_id_for_round_trip() {
panic!("expected RequestPermissions event");
};
assert_eq!(request.call_id, call_id.clone());
assert_eq!(request.cwd, Some(delegated_cwd));
parent_session
.notify_request_permissions_response(&call_id, expected_response.clone())
+11 -4
View File
@@ -7,6 +7,7 @@ use crate::config_loader::ConfigLayerStackOrdering;
use crate::config_loader::ConfigRequirements;
use crate::config_loader::ConfigRequirementsToml;
use crate::config_loader::ConstrainedWithSource;
use crate::config_loader::FeatureRequirementsToml;
use crate::config_loader::LoaderOverrides;
use crate::config_loader::McpServerIdentity;
use crate::config_loader::McpServerRequirement;
@@ -108,7 +109,6 @@ mod network_proxy_spec;
mod permissions;
#[cfg(test)]
mod schema;
pub(crate) mod service;
pub use codex_config::Constrained;
pub use codex_config::ConstraintError;
pub use codex_config::ConstraintResult;
@@ -118,8 +118,6 @@ pub use managed_features::ManagedFeatures;
pub use network_proxy_spec::NetworkProxySpec;
pub use network_proxy_spec::StartedNetworkProxy;
pub(crate) use permissions::resolve_permission_profile;
pub use service::ConfigService;
pub use service::ConfigServiceError;
pub use codex_git_utils::GhostSnapshotConfig;
@@ -947,7 +945,7 @@ pub async fn load_config_as_toml_with_cli_and_loader_overrides(
Ok(cfg)
}
pub(crate) fn deserialize_config_toml_with_base(
pub fn deserialize_config_toml_with_base(
root_value: TomlValue,
config_base_dir: &Path,
) -> std::io::Result<ConfigToml> {
@@ -959,6 +957,15 @@ pub(crate) fn deserialize_config_toml_with_base(
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
}
/// Validate user-visible feature settings against managed feature requirements.
pub fn validate_feature_requirements_for_config_toml(
cfg: &ConfigToml,
feature_requirements: Option<&Sourced<FeatureRequirementsToml>>,
) -> std::io::Result<()> {
managed_features::validate_explicit_feature_settings_in_config_toml(cfg, feature_requirements)?;
managed_features::validate_feature_requirements_in_config_toml(cfg, feature_requirements)
}
fn load_catalog_json(path: &AbsolutePathBuf) -> std::io::Result<ModelsResponse> {
let file_contents = std::fs::read_to_string(path)?;
let catalog = serde_json::from_str::<ModelsResponse>(&file_contents).map_err(|err| {
@@ -0,0 +1,24 @@
use super::ContextualUserFragment;
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct ApprovedCommandPrefixSaved {
prefixes: String,
}
impl ApprovedCommandPrefixSaved {
pub(crate) fn new(prefixes: impl Into<String>) -> Self {
Self {
prefixes: prefixes.into(),
}
}
}
impl ContextualUserFragment for ApprovedCommandPrefixSaved {
const ROLE: &'static str = "developer";
const START_MARKER: &'static str = "";
const END_MARKER: &'static str = "";
fn body(&self) -> String {
format!("Approved command prefix saved:\n{}", self.prefixes)
}
}
@@ -0,0 +1,30 @@
use codex_app_server_protocol::AppInfo;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_protocol::protocol::APPS_INSTRUCTIONS_CLOSE_TAG;
use codex_protocol::protocol::APPS_INSTRUCTIONS_OPEN_TAG;
use super::ContextualUserFragment;
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct AppsInstructions;
impl AppsInstructions {
pub(crate) fn from_connectors(connectors: &[AppInfo]) -> Option<Self> {
connectors
.iter()
.any(|connector| connector.is_accessible && connector.is_enabled)
.then_some(Self)
}
}
impl ContextualUserFragment for AppsInstructions {
const ROLE: &'static str = "developer";
const START_MARKER: &'static str = APPS_INSTRUCTIONS_OPEN_TAG;
const END_MARKER: &'static str = APPS_INSTRUCTIONS_CLOSE_TAG;
fn body(&self) -> String {
format!(
"\n## Apps (Connectors)\nApps (Connectors) can be explicitly triggered in user messages in the format `[$app-name](app://{{connector_id}})`. Apps can also be implicitly triggered as long as the context suggests usage of available apps.\nAn app is equivalent to a set of MCP tools within the `{CODEX_APPS_MCP_SERVER_NAME}` MCP.\nAn installed app's MCP tools are either provided to you already, or can be lazy-loaded through the `tool_search` tool. If `tool_search` is available, the apps that are searchable by `tools_search` will be listed by it.\nDo not additionally call list_mcp_resources or list_mcp_resource_templates for apps.\n"
)
}
}
@@ -0,0 +1,58 @@
use codex_plugin::PluginCapabilitySummary;
use codex_protocol::protocol::PLUGINS_INSTRUCTIONS_CLOSE_TAG;
use codex_protocol::protocol::PLUGINS_INSTRUCTIONS_OPEN_TAG;
use super::ContextualUserFragment;
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct AvailablePluginsInstructions {
plugins: Vec<PluginCapabilitySummary>,
}
impl AvailablePluginsInstructions {
pub(crate) fn from_plugins(plugins: &[PluginCapabilitySummary]) -> Option<Self> {
if plugins.is_empty() {
return None;
}
Some(Self {
plugins: plugins.to_vec(),
})
}
}
impl ContextualUserFragment for AvailablePluginsInstructions {
const ROLE: &'static str = "developer";
const START_MARKER: &'static str = PLUGINS_INSTRUCTIONS_OPEN_TAG;
const END_MARKER: &'static str = PLUGINS_INSTRUCTIONS_CLOSE_TAG;
fn body(&self) -> String {
let mut lines = vec![
"## Plugins".to_string(),
"A plugin is a local bundle of skills, MCP servers, and apps. Below is the list of plugins that are enabled and available in this session.".to_string(),
"### Available plugins".to_string(),
];
lines.extend(
self.plugins
.iter()
.map(|plugin| match plugin.description.as_deref() {
Some(description) => format!("- `{}`: {description}", plugin.display_name),
None => format!("- `{}`", plugin.display_name),
}),
);
lines.push("### How to use plugins".to_string());
lines.push(
r###"- Discovery: The list above is the plugins available in this session.
- Skill naming: If a plugin contributes skills, those skill entries are prefixed with `plugin_name:` in the Skills list.
- Trigger rules: If the user explicitly names a plugin, prefer capabilities associated with that plugin for that turn.
- Relationship to capabilities: Plugins are not invoked directly. Use their underlying skills, MCP tools, and app tools to help solve the task.
- Preference: When a relevant plugin is available, prefer using capabilities associated with that plugin over standalone capabilities that provide similar functionality.
- Missing/blocked: If the user requests a plugin that is not listed above, or the plugin does not have relevant callable capabilities for the task, say so briefly and continue with the best fallback."###
.to_string(),
);
format!("\n{}\n", lines.join("\n"))
}
}
@@ -0,0 +1,56 @@
use codex_core_skills::AvailableSkills;
use codex_protocol::protocol::SKILLS_INSTRUCTIONS_CLOSE_TAG;
use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG;
use super::ContextualUserFragment;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct AvailableSkillsInstructions {
skill_lines: Vec<String>,
}
impl From<AvailableSkills> for AvailableSkillsInstructions {
fn from(available_skills: AvailableSkills) -> Self {
Self {
skill_lines: available_skills.skill_lines,
}
}
}
impl ContextualUserFragment for AvailableSkillsInstructions {
const ROLE: &'static str = "developer";
const START_MARKER: &'static str = SKILLS_INSTRUCTIONS_OPEN_TAG;
const END_MARKER: &'static str = SKILLS_INSTRUCTIONS_CLOSE_TAG;
fn body(&self) -> String {
let mut lines: Vec<String> = Vec::new();
lines.push("## Skills".to_string());
lines.push("A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and file path so you can open the source for full instructions when using a specific skill.".to_string());
lines.push("### Available skills".to_string());
lines.extend(self.skill_lines.iter().cloned());
lines.push("### How to use skills".to_string());
lines.push(
r###"- Discovery: The list above is the skills available in this session (name + description + file path). Skill bodies live on disk at the listed paths.
- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description shown above, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.
- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.
- How to use a skill (progressive disclosure):
1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow.
2) When `SKILL.md` references relative paths (e.g., `scripts/foo.py`), resolve them relative to the skill directory listed above first, and only consider other paths if needed.
3) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything.
4) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.
5) If `assets/` or templates exist, reuse them instead of recreating from scratch.
- Coordination and sequencing:
- If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.
- Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.
- Context hygiene:
- Keep context small: summarize long sections instead of pasting them; only load extra files when needed.
- Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked.
- When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice.
- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue."###
.to_string(),
);
format!("\n{}\n", lines.join("\n"))
}
}
@@ -0,0 +1,32 @@
use super::ContextualUserFragment;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::protocol::COLLABORATION_MODE_CLOSE_TAG;
use codex_protocol::protocol::COLLABORATION_MODE_OPEN_TAG;
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct CollaborationModeInstructions {
instructions: String,
}
impl CollaborationModeInstructions {
pub(crate) fn from_collaboration_mode(collaboration_mode: &CollaborationMode) -> Option<Self> {
collaboration_mode
.settings
.developer_instructions
.as_ref()
.filter(|instructions| !instructions.is_empty())
.map(|instructions| Self {
instructions: instructions.clone(),
})
}
}
impl ContextualUserFragment for CollaborationModeInstructions {
const ROLE: &'static str = "developer";
const START_MARKER: &'static str = COLLABORATION_MODE_OPEN_TAG;
const END_MARKER: &'static str = COLLABORATION_MODE_CLOSE_TAG;
fn body(&self) -> String {
self.instructions.clone()
}
}
@@ -200,7 +200,7 @@ impl ContextualUserFragment for EnvironmentContext {
lines.extend(subagents.lines().map(|line| format!(" {line}")));
lines.push(" </subagents>".to_string());
}
format!("\n{}", lines.join("\n"))
format!("\n{}\n", lines.join("\n"))
}
}
+18 -12
View File
@@ -28,13 +28,16 @@ impl<T: ContextualUserFragment> FragmentRegistration for FragmentRegistrationPro
}
}
/// Context payload that is injected as a user-authored message fragment.
/// Context payload that is injected as a message fragment.
///
/// Implementations own the response role, start/end markers used to recognize
/// the fragment, and provide the fragment body appended directly after the
/// start marker. The default helpers wrap that body and convert it into the
/// response item shape expected by model input assembly.
pub(crate) trait ContextualUserFragment {
/// Implementations own the response role and provide the exact fragment body.
/// Marked fragments also provide start/end markers used to recognize injected
/// context later. `render()` concatenates markers and body without adding
/// separators, so implementations should include any whitespace they need
/// between tags in `body()`. Unmarked fragments should leave both markers empty,
/// in which case the default helpers render only the body and never match
/// arbitrary text.
pub trait ContextualUserFragment {
const ROLE: &'static str;
const START_MARKER: &'static str;
const END_MARKER: &'static str;
@@ -45,6 +48,10 @@ pub(crate) trait ContextualUserFragment {
where
Self: Sized,
{
if Self::START_MARKER.is_empty() || Self::END_MARKER.is_empty() {
return false;
}
let trimmed = text.trim_start();
let starts_with_marker = trimmed
.get(..Self::START_MARKER.len())
@@ -57,12 +64,11 @@ pub(crate) trait ContextualUserFragment {
}
fn render(&self) -> String {
format!(
"{}{}\n{}",
Self::START_MARKER,
self.body(),
Self::END_MARKER
)
if Self::START_MARKER.is_empty() && Self::END_MARKER.is_empty() {
return self.body();
}
format!("{}{}{}", Self::START_MARKER, self.body(), Self::END_MARKER)
}
fn into(self) -> ResponseItem
@@ -0,0 +1,21 @@
use super::ContextualUserFragment;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct GuardianFollowupReviewReminder;
impl ContextualUserFragment for GuardianFollowupReviewReminder {
const ROLE: &'static str = "developer";
const START_MARKER: &'static str = "";
const END_MARKER: &'static str = "";
fn body(&self) -> String {
concat!(
"Use prior reviews as context, not binding precedent. ",
"Follow the Workspace Policy. ",
"If the user explicitly approves a previously rejected action after being informed of the ",
"concrete risks, set outcome to \"allow\" unless the policy explicitly disallows user ",
"overwrites in such cases."
)
.to_string()
}
}
@@ -0,0 +1,22 @@
use super::ContextualUserFragment;
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct HookAdditionalContext {
text: String,
}
impl HookAdditionalContext {
pub(crate) fn new(text: impl Into<String>) -> Self {
Self { text: text.into() }
}
}
impl ContextualUserFragment for HookAdditionalContext {
const ROLE: &'static str = "developer";
const START_MARKER: &'static str = "";
const END_MARKER: &'static str = "";
fn body(&self) -> String {
self.text.clone()
}
}
@@ -0,0 +1,30 @@
use super::ContextualUserFragment;
use std::fmt::Display;
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct ImageGenerationInstructions {
image_output_dir: String,
image_output_path: String,
}
impl ImageGenerationInstructions {
pub(crate) fn new(image_output_dir: impl Display, image_output_path: impl Display) -> Self {
Self {
image_output_dir: image_output_dir.to_string(),
image_output_path: image_output_path.to_string(),
}
}
}
impl ContextualUserFragment for ImageGenerationInstructions {
const ROLE: &'static str = "developer";
const START_MARKER: &'static str = "";
const END_MARKER: &'static str = "";
fn body(&self) -> String {
format!(
"Generated images are saved to {} as {} by default.\nIf you need to use a generated image at another path, copy it and leave the original in place unless the user explicitly asks you to delete it.",
self.image_output_dir, self.image_output_path
)
}
}
+37 -1
View File
@@ -1,20 +1,56 @@
//! Context fragments injected into model input.
mod approved_command_prefix_saved;
mod apps_instructions;
mod available_plugins_instructions;
mod available_skills_instructions;
mod collaboration_mode_instructions;
mod contextual_user_message;
mod environment_context;
mod fragment;
mod guardian_followup_review_reminder;
mod hook_additional_context;
mod image_generation_instructions;
mod model_switch_instructions;
mod network_rule_saved;
mod permissions_instructions;
mod personality_spec_instructions;
mod plugin_instructions;
mod realtime_end_instructions;
mod realtime_start_instructions;
mod realtime_start_with_instructions;
mod skill_instructions;
mod spawn_agent_instructions;
mod subagent_notification;
mod turn_aborted;
mod user_instructions;
mod user_shell_command;
pub(crate) use approved_command_prefix_saved::ApprovedCommandPrefixSaved;
pub(crate) use apps_instructions::AppsInstructions;
pub(crate) use available_plugins_instructions::AvailablePluginsInstructions;
pub(crate) use available_skills_instructions::AvailableSkillsInstructions;
pub(crate) use collaboration_mode_instructions::CollaborationModeInstructions;
pub(crate) use contextual_user_message::is_contextual_user_fragment;
pub(crate) use contextual_user_message::is_memory_excluded_contextual_user_fragment;
pub(crate) use contextual_user_message::parse_visible_hook_prompt_message;
pub(crate) use environment_context::EnvironmentContext;
pub(crate) use fragment::ContextualUserFragment;
pub use fragment::ContextualUserFragment;
pub(crate) use fragment::FragmentRegistration;
pub(crate) use fragment::FragmentRegistrationProxy;
pub(crate) use guardian_followup_review_reminder::GuardianFollowupReviewReminder;
pub(crate) use hook_additional_context::HookAdditionalContext;
pub(crate) use image_generation_instructions::ImageGenerationInstructions;
pub(crate) use model_switch_instructions::ModelSwitchInstructions;
pub(crate) use network_rule_saved::NetworkRuleSaved;
pub use permissions_instructions::PermissionsInstructions;
pub(crate) use personality_spec_instructions::PersonalitySpecInstructions;
pub(crate) use plugin_instructions::PluginInstructions;
pub(crate) use realtime_end_instructions::RealtimeEndInstructions;
pub(crate) use realtime_start_instructions::RealtimeStartInstructions;
pub(crate) use realtime_start_with_instructions::RealtimeStartWithInstructions;
pub(crate) use skill_instructions::SkillInstructions;
pub(crate) use spawn_agent_instructions::SpawnAgentInstructions;
pub(crate) use subagent_notification::SubagentNotification;
pub(crate) use turn_aborted::TurnAborted;
pub(crate) use user_instructions::UserInstructions;
@@ -0,0 +1,27 @@
use super::ContextualUserFragment;
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct ModelSwitchInstructions {
model_instructions: String,
}
impl ModelSwitchInstructions {
pub(crate) fn new(model_instructions: impl Into<String>) -> Self {
Self {
model_instructions: model_instructions.into(),
}
}
}
impl ContextualUserFragment for ModelSwitchInstructions {
const ROLE: &'static str = "developer";
const START_MARKER: &'static str = "<model_switch>";
const END_MARKER: &'static str = "</model_switch>";
fn body(&self) -> String {
format!(
"\nThe user was previously using a different model. Please continue the conversation according to the following instructions:\n\n{}\n",
self.model_instructions
)
}
}
@@ -0,0 +1,35 @@
use super::ContextualUserFragment;
use codex_protocol::approvals::NetworkPolicyAmendment;
use codex_protocol::approvals::NetworkPolicyRuleAction;
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct NetworkRuleSaved {
action: NetworkPolicyRuleAction,
host: String,
}
impl NetworkRuleSaved {
pub(crate) fn new(amendment: &NetworkPolicyAmendment) -> Self {
Self {
action: amendment.action,
host: amendment.host.clone(),
}
}
}
impl ContextualUserFragment for NetworkRuleSaved {
const ROLE: &'static str = "developer";
const START_MARKER: &'static str = "";
const END_MARKER: &'static str = "";
fn body(&self) -> String {
let (action, list_name) = match self.action {
NetworkPolicyRuleAction::Allow => ("Allowed", "allowlist"),
NetworkPolicyRuleAction::Deny => ("Denied", "denylist"),
};
format!(
"{action} network rule saved in execpolicy ({list_name}): {}",
self.host
)
}
}
@@ -0,0 +1,316 @@
use super::ContextualUserFragment;
use codex_execpolicy::Policy;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::config_types::SandboxMode;
use codex_protocol::models::format_allow_prefixes;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::GranularApprovalConfig;
use codex_protocol::protocol::NetworkAccess;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::WritableRoot;
use codex_utils_template::Template;
use std::path::Path;
use std::sync::LazyLock;
const APPROVAL_POLICY_NEVER: &str = include_str!("prompts/permissions/approval_policy/never.md");
const APPROVAL_POLICY_UNLESS_TRUSTED: &str =
include_str!("prompts/permissions/approval_policy/unless_trusted.md");
const APPROVAL_POLICY_ON_FAILURE: &str =
include_str!("prompts/permissions/approval_policy/on_failure.md");
const APPROVAL_POLICY_ON_REQUEST_RULE: &str =
include_str!("prompts/permissions/approval_policy/on_request.md");
const APPROVAL_POLICY_ON_REQUEST_RULE_REQUEST_PERMISSION: &str =
include_str!("prompts/permissions/approval_policy/on_request_rule_request_permission.md");
const AUTO_REVIEW_APPROVAL_SUFFIX: &str = "`approvals_reviewer` is `auto_review`: Sandbox escalations with require_escalated will be reviewed for compliance with the policy. If a rejection happens, you should proceed only with a materially safer alternative, or inform the user of the risk and send a final message to ask for approval.";
const SANDBOX_MODE_DANGER_FULL_ACCESS: &str =
include_str!("prompts/permissions/sandbox_mode/danger_full_access.md");
const SANDBOX_MODE_WORKSPACE_WRITE: &str =
include_str!("prompts/permissions/sandbox_mode/workspace_write.md");
const SANDBOX_MODE_READ_ONLY: &str = include_str!("prompts/permissions/sandbox_mode/read_only.md");
static SANDBOX_MODE_DANGER_FULL_ACCESS_TEMPLATE: LazyLock<Template> = LazyLock::new(|| {
Template::parse(SANDBOX_MODE_DANGER_FULL_ACCESS.trim_end())
.unwrap_or_else(|err| panic!("danger-full-access sandbox template must parse: {err}"))
});
static SANDBOX_MODE_WORKSPACE_WRITE_TEMPLATE: LazyLock<Template> = LazyLock::new(|| {
Template::parse(SANDBOX_MODE_WORKSPACE_WRITE.trim_end())
.unwrap_or_else(|err| panic!("workspace-write sandbox template must parse: {err}"))
});
static SANDBOX_MODE_READ_ONLY_TEMPLATE: LazyLock<Template> = LazyLock::new(|| {
Template::parse(SANDBOX_MODE_READ_ONLY.trim_end())
.unwrap_or_else(|err| panic!("read-only sandbox template must parse: {err}"))
});
struct PermissionsPromptConfig<'a> {
approval_policy: AskForApproval,
approvals_reviewer: ApprovalsReviewer,
exec_policy: &'a Policy,
exec_permission_approvals_enabled: bool,
request_permissions_tool_enabled: bool,
}
#[derive(Debug, Clone, PartialEq)]
/// Developer instructions that describe the active sandbox and approval policy.
pub struct PermissionsInstructions {
text: String,
}
impl PermissionsInstructions {
/// Builds permissions instructions from the effective sandbox and approval policy.
pub fn from_policy(
sandbox_policy: &SandboxPolicy,
approval_policy: AskForApproval,
approvals_reviewer: ApprovalsReviewer,
exec_policy: &Policy,
cwd: &Path,
exec_permission_approvals_enabled: bool,
request_permissions_tool_enabled: bool,
) -> Self {
let network_access = if sandbox_policy.has_full_network_access() {
NetworkAccess::Enabled
} else {
NetworkAccess::Restricted
};
let (sandbox_mode, writable_roots) = match sandbox_policy {
SandboxPolicy::DangerFullAccess => (SandboxMode::DangerFullAccess, None),
SandboxPolicy::ReadOnly { .. } => (SandboxMode::ReadOnly, None),
SandboxPolicy::ExternalSandbox { .. } => (SandboxMode::DangerFullAccess, None),
SandboxPolicy::WorkspaceWrite { .. } => {
let roots = sandbox_policy.get_writable_roots_with_cwd(cwd);
(SandboxMode::WorkspaceWrite, Some(roots))
}
};
Self::from_permissions_with_network(
sandbox_mode,
network_access,
PermissionsPromptConfig {
approval_policy,
approvals_reviewer,
exec_policy,
exec_permission_approvals_enabled,
request_permissions_tool_enabled,
},
writable_roots,
)
}
fn from_permissions_with_network(
sandbox_mode: SandboxMode,
network_access: NetworkAccess,
config: PermissionsPromptConfig<'_>,
writable_roots: Option<Vec<WritableRoot>>,
) -> Self {
let mut text = String::new();
append_section(&mut text, &sandbox_text(sandbox_mode, network_access));
append_section(
&mut text,
&approval_text(
config.approval_policy,
config.approvals_reviewer,
config.exec_policy,
config.exec_permission_approvals_enabled,
config.request_permissions_tool_enabled,
),
);
if let Some(writable_roots) = writable_roots_text(writable_roots) {
append_section(&mut text, &writable_roots);
}
if !text.ends_with('\n') {
text.push('\n');
}
Self { text }
}
}
impl ContextualUserFragment for PermissionsInstructions {
const ROLE: &'static str = "developer";
const START_MARKER: &'static str = "<permissions instructions>";
const END_MARKER: &'static str = "</permissions instructions>";
fn body(&self) -> String {
self.text.clone()
}
}
fn append_section(text: &mut String, section: &str) {
if !text.ends_with('\n') {
text.push('\n');
}
text.push_str(section);
}
fn approval_text(
approval_policy: AskForApproval,
approvals_reviewer: ApprovalsReviewer,
exec_policy: &Policy,
exec_permission_approvals_enabled: bool,
request_permissions_tool_enabled: bool,
) -> String {
let with_request_permissions_tool = |text: &str| {
if request_permissions_tool_enabled {
format!("{text}\n\n{}", request_permissions_tool_prompt_section())
} else {
text.to_string()
}
};
let on_request_instructions = || {
let on_request_rule = if exec_permission_approvals_enabled {
APPROVAL_POLICY_ON_REQUEST_RULE_REQUEST_PERMISSION.to_string()
} else {
APPROVAL_POLICY_ON_REQUEST_RULE.to_string()
};
let mut sections = vec![on_request_rule];
if request_permissions_tool_enabled {
sections.push(request_permissions_tool_prompt_section().to_string());
}
if let Some(prefixes) = approved_command_prefixes_text(exec_policy) {
sections.push(format!(
"## Approved command prefixes\nThe following prefix rules have already been approved: {prefixes}"
));
}
sections.join("\n\n")
};
let text = match approval_policy {
AskForApproval::Never => APPROVAL_POLICY_NEVER.to_string(),
AskForApproval::UnlessTrusted => {
with_request_permissions_tool(APPROVAL_POLICY_UNLESS_TRUSTED)
}
AskForApproval::OnFailure => with_request_permissions_tool(APPROVAL_POLICY_ON_FAILURE),
AskForApproval::OnRequest => on_request_instructions(),
AskForApproval::Granular(granular_config) => granular_instructions(
granular_config,
exec_policy,
exec_permission_approvals_enabled,
request_permissions_tool_enabled,
),
};
if approvals_reviewer == ApprovalsReviewer::GuardianSubagent
&& approval_policy != AskForApproval::Never
{
format!("{text}\n\n{AUTO_REVIEW_APPROVAL_SUFFIX}")
} else {
text
}
}
fn sandbox_text(mode: SandboxMode, network_access: NetworkAccess) -> String {
let template = match mode {
SandboxMode::DangerFullAccess => &*SANDBOX_MODE_DANGER_FULL_ACCESS_TEMPLATE,
SandboxMode::WorkspaceWrite => &*SANDBOX_MODE_WORKSPACE_WRITE_TEMPLATE,
SandboxMode::ReadOnly => &*SANDBOX_MODE_READ_ONLY_TEMPLATE,
};
let network_access = network_access.to_string();
template
.render([("network_access", network_access.as_str())])
.unwrap_or_else(|err| panic!("sandbox template must render: {err}"))
}
fn writable_roots_text(writable_roots: Option<Vec<WritableRoot>>) -> Option<String> {
let roots = writable_roots?;
if roots.is_empty() {
return None;
}
let roots_list: Vec<String> = roots
.iter()
.map(|r| format!("`{}`", r.root.to_string_lossy()))
.collect();
Some(if roots_list.len() == 1 {
format!(" The writable root is {}.", roots_list[0])
} else {
format!(" The writable roots are {}.", roots_list.join(", "))
})
}
fn approved_command_prefixes_text(exec_policy: &Policy) -> Option<String> {
format_allow_prefixes(exec_policy.get_allowed_prefixes())
.filter(|prefixes| !prefixes.is_empty())
}
fn granular_prompt_intro_text() -> &'static str {
"# Approval Requests\n\nApproval policy is `granular`. Categories set to `false` are automatically rejected instead of prompting the user."
}
fn request_permissions_tool_prompt_section() -> &'static str {
"# request_permissions Tool\n\nThe built-in `request_permissions` tool is available in this session. Invoke it when you need to request additional `network` or `file_system` permissions before later shell-like commands need them. Request only the specific permissions required for the task."
}
fn granular_instructions(
granular_config: GranularApprovalConfig,
exec_policy: &Policy,
exec_permission_approvals_enabled: bool,
request_permissions_tool_enabled: bool,
) -> String {
let sandbox_approval_prompts_allowed = granular_config.allows_sandbox_approval();
let shell_permission_requests_available =
exec_permission_approvals_enabled && sandbox_approval_prompts_allowed;
let request_permissions_tool_prompts_allowed =
request_permissions_tool_enabled && granular_config.allows_request_permissions();
let categories = [
Some((
granular_config.allows_sandbox_approval(),
"`sandbox_approval`",
)),
Some((granular_config.allows_rules_approval(), "`rules`")),
Some((granular_config.allows_skill_approval(), "`skill_approval`")),
request_permissions_tool_enabled.then_some((
granular_config.allows_request_permissions(),
"`request_permissions`",
)),
Some((
granular_config.allows_mcp_elicitations(),
"`mcp_elicitations`",
)),
];
let prompted_categories = categories
.iter()
.flatten()
.filter(|&&(is_allowed, _)| is_allowed)
.map(|&(_, category)| format!("- {category}"))
.collect::<Vec<_>>();
let rejected_categories = categories
.iter()
.flatten()
.filter(|&&(is_allowed, _)| !is_allowed)
.map(|&(_, category)| format!("- {category}"))
.collect::<Vec<_>>();
let mut sections = vec![granular_prompt_intro_text().to_string()];
if !prompted_categories.is_empty() {
sections.push(format!(
"These approval categories may still prompt the user when needed:\n{}",
prompted_categories.join("\n")
));
}
if !rejected_categories.is_empty() {
sections.push(format!(
"These approval categories are automatically rejected instead of prompting the user:\n{}",
rejected_categories.join("\n")
));
}
if shell_permission_requests_available {
sections.push(APPROVAL_POLICY_ON_REQUEST_RULE_REQUEST_PERMISSION.to_string());
}
if request_permissions_tool_prompts_allowed {
sections.push(request_permissions_tool_prompt_section().to_string());
}
if let Some(prefixes) = approved_command_prefixes_text(exec_policy) {
sections.push(format!(
"## Approved command prefixes\nThe following prefix rules have already been approved: {prefixes}"
));
}
sections.join("\n\n")
}
#[cfg(test)]
#[path = "permissions_instructions_tests.rs"]
mod permissions_instructions_tests;
@@ -0,0 +1,412 @@
use super::*;
use codex_execpolicy::Decision;
use pretty_assertions::assert_eq;
use std::path::PathBuf;
#[test]
fn renders_sandbox_mode_text() {
assert_eq!(
sandbox_text(SandboxMode::WorkspaceWrite, NetworkAccess::Restricted),
"Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `workspace-write`: The sandbox permits reading files, and editing files in `cwd` and `writable_roots`. Editing files in other directories requires approval. Network access is restricted."
);
assert_eq!(
sandbox_text(SandboxMode::ReadOnly, NetworkAccess::Restricted),
"Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `read-only`: The sandbox only permits reading files. Network access is restricted."
);
assert_eq!(
sandbox_text(SandboxMode::DangerFullAccess, NetworkAccess::Enabled),
"Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `danger-full-access`: No filesystem sandboxing - all commands are permitted. Network access is enabled."
);
}
#[test]
fn builds_permissions_with_network_access_override() {
let instructions = PermissionsInstructions::from_permissions_with_network(
SandboxMode::WorkspaceWrite,
NetworkAccess::Enabled,
PermissionsPromptConfig {
approval_policy: AskForApproval::OnRequest,
approvals_reviewer: ApprovalsReviewer::User,
exec_policy: &Policy::empty(),
exec_permission_approvals_enabled: false,
request_permissions_tool_enabled: false,
},
/*writable_roots*/ None,
);
let text = instructions.body();
assert!(
text.contains("Network access is enabled."),
"expected network access to be enabled in message"
);
assert!(
text.contains("How to request escalation"),
"expected approval guidance to be included"
);
}
#[test]
fn builds_permissions_from_policy() {
let policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
read_only_access: Default::default(),
network_access: true,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
};
let instructions = PermissionsInstructions::from_policy(
&policy,
AskForApproval::UnlessTrusted,
ApprovalsReviewer::User,
&Policy::empty(),
&PathBuf::from("/tmp"),
/*exec_permission_approvals_enabled*/ false,
/*request_permissions_tool_enabled*/ false,
);
let text = instructions.body();
assert!(text.contains("Network access is enabled."));
assert!(text.contains("`approval_policy` is `unless-trusted`"));
}
#[test]
fn includes_request_rule_instructions_for_on_request() {
let mut exec_policy = Policy::empty();
exec_policy
.add_prefix_rule(&["git".to_string(), "pull".to_string()], Decision::Allow)
.expect("add rule");
let instructions = PermissionsInstructions::from_permissions_with_network(
SandboxMode::WorkspaceWrite,
NetworkAccess::Enabled,
PermissionsPromptConfig {
approval_policy: AskForApproval::OnRequest,
approvals_reviewer: ApprovalsReviewer::User,
exec_policy: &exec_policy,
exec_permission_approvals_enabled: false,
request_permissions_tool_enabled: false,
},
/*writable_roots*/ None,
);
let text = instructions.body();
assert!(text.contains("prefix_rule"));
assert!(text.contains("Approved command prefixes"));
assert!(text.contains(r#"["git", "pull"]"#));
}
#[test]
fn includes_request_permissions_tool_instructions_for_unless_trusted_when_enabled() {
let instructions = PermissionsInstructions::from_permissions_with_network(
SandboxMode::WorkspaceWrite,
NetworkAccess::Enabled,
PermissionsPromptConfig {
approval_policy: AskForApproval::UnlessTrusted,
approvals_reviewer: ApprovalsReviewer::User,
exec_policy: &Policy::empty(),
exec_permission_approvals_enabled: false,
request_permissions_tool_enabled: true,
},
/*writable_roots*/ None,
);
let text = instructions.body();
assert!(text.contains("`approval_policy` is `unless-trusted`"));
assert!(text.contains("# request_permissions Tool"));
}
#[test]
fn includes_request_permissions_tool_instructions_for_on_failure_when_enabled() {
let instructions = PermissionsInstructions::from_permissions_with_network(
SandboxMode::WorkspaceWrite,
NetworkAccess::Enabled,
PermissionsPromptConfig {
approval_policy: AskForApproval::OnFailure,
approvals_reviewer: ApprovalsReviewer::User,
exec_policy: &Policy::empty(),
exec_permission_approvals_enabled: false,
request_permissions_tool_enabled: true,
},
/*writable_roots*/ None,
);
let text = instructions.body();
assert!(text.contains("`approval_policy` is `on-failure`"));
assert!(text.contains("# request_permissions Tool"));
}
#[test]
fn includes_request_permission_rule_instructions_for_on_request_when_enabled() {
let instructions = PermissionsInstructions::from_permissions_with_network(
SandboxMode::WorkspaceWrite,
NetworkAccess::Enabled,
PermissionsPromptConfig {
approval_policy: AskForApproval::OnRequest,
approvals_reviewer: ApprovalsReviewer::User,
exec_policy: &Policy::empty(),
exec_permission_approvals_enabled: true,
request_permissions_tool_enabled: false,
},
/*writable_roots*/ None,
);
let text = instructions.body();
assert!(text.contains("with_additional_permissions"));
assert!(text.contains("additional_permissions"));
}
#[test]
fn includes_request_permissions_tool_instructions_for_on_request_when_tool_is_enabled() {
let instructions = PermissionsInstructions::from_permissions_with_network(
SandboxMode::WorkspaceWrite,
NetworkAccess::Enabled,
PermissionsPromptConfig {
approval_policy: AskForApproval::OnRequest,
approvals_reviewer: ApprovalsReviewer::User,
exec_policy: &Policy::empty(),
exec_permission_approvals_enabled: false,
request_permissions_tool_enabled: true,
},
/*writable_roots*/ None,
);
let text = instructions.body();
assert!(text.contains("# request_permissions Tool"));
assert!(text.contains("The built-in `request_permissions` tool is available in this session."));
}
#[test]
fn on_request_includes_tool_guidance_alongside_inline_permission_guidance_when_both_exist() {
let instructions = PermissionsInstructions::from_permissions_with_network(
SandboxMode::WorkspaceWrite,
NetworkAccess::Enabled,
PermissionsPromptConfig {
approval_policy: AskForApproval::OnRequest,
approvals_reviewer: ApprovalsReviewer::User,
exec_policy: &Policy::empty(),
exec_permission_approvals_enabled: true,
request_permissions_tool_enabled: true,
},
/*writable_roots*/ None,
);
let text = instructions.body();
assert!(text.contains("with_additional_permissions"));
assert!(text.contains("# request_permissions Tool"));
}
#[test]
fn guardian_subagent_approvals_append_guardian_specific_guidance() {
let text = approval_text(
AskForApproval::OnRequest,
ApprovalsReviewer::GuardianSubagent,
&Policy::empty(),
/*exec_permission_approvals_enabled*/ false,
/*request_permissions_tool_enabled*/ false,
);
assert!(text.contains("`approvals_reviewer` is `auto_review`"));
assert!(!text.contains("`approvals_reviewer` is `guardian_subagent`"));
assert!(text.contains("materially safer alternative"));
}
#[test]
fn guardian_subagent_approvals_omit_guardian_specific_guidance_when_approval_is_never() {
let text = approval_text(
AskForApproval::Never,
ApprovalsReviewer::GuardianSubagent,
&Policy::empty(),
/*exec_permission_approvals_enabled*/ false,
/*request_permissions_tool_enabled*/ false,
);
assert!(!text.contains("`approvals_reviewer` is `auto_review`"));
assert!(!text.contains("`approvals_reviewer` is `guardian_subagent`"));
}
fn granular_categories_section(title: &str, categories: &[&str]) -> String {
format!("{title}\n{}", categories.join("\n"))
}
fn granular_prompt_expected(
prompted_categories: &[&str],
rejected_categories: &[&str],
include_shell_permission_request_instructions: bool,
include_request_permissions_tool_section: bool,
) -> String {
let mut sections = vec![granular_prompt_intro_text().to_string()];
if !prompted_categories.is_empty() {
sections.push(granular_categories_section(
"These approval categories may still prompt the user when needed:",
prompted_categories,
));
}
if !rejected_categories.is_empty() {
sections.push(granular_categories_section(
"These approval categories are automatically rejected instead of prompting the user:",
rejected_categories,
));
}
if include_shell_permission_request_instructions {
sections.push(APPROVAL_POLICY_ON_REQUEST_RULE_REQUEST_PERMISSION.to_string());
}
if include_request_permissions_tool_section {
sections.push(request_permissions_tool_prompt_section().to_string());
}
sections.join("\n\n")
}
#[test]
fn granular_policy_lists_prompted_and_rejected_categories_separately() {
let text = approval_text(
AskForApproval::Granular(GranularApprovalConfig {
sandbox_approval: false,
rules: true,
skill_approval: false,
request_permissions: true,
mcp_elicitations: false,
}),
ApprovalsReviewer::User,
&Policy::empty(),
/*exec_permission_approvals_enabled*/ true,
/*request_permissions_tool_enabled*/ false,
);
assert_eq!(
text,
[
granular_prompt_intro_text().to_string(),
granular_categories_section(
"These approval categories may still prompt the user when needed:",
&["- `rules`"],
),
granular_categories_section(
"These approval categories are automatically rejected instead of prompting the user:",
&[
"- `sandbox_approval`",
"- `skill_approval`",
"- `mcp_elicitations`",
],
),
]
.join("\n\n")
);
}
#[test]
fn granular_policy_includes_command_permission_instructions_when_sandbox_approval_can_prompt() {
let text = approval_text(
AskForApproval::Granular(GranularApprovalConfig {
sandbox_approval: true,
rules: true,
skill_approval: true,
request_permissions: true,
mcp_elicitations: true,
}),
ApprovalsReviewer::User,
&Policy::empty(),
/*exec_permission_approvals_enabled*/ true,
/*request_permissions_tool_enabled*/ false,
);
assert_eq!(
text,
granular_prompt_expected(
&[
"- `sandbox_approval`",
"- `rules`",
"- `skill_approval`",
"- `mcp_elicitations`",
],
&[],
/*include_shell_permission_request_instructions*/ true,
/*include_request_permissions_tool_section*/ false,
)
);
}
#[test]
fn granular_policy_omits_shell_permission_instructions_when_inline_requests_are_disabled() {
let text = approval_text(
AskForApproval::Granular(GranularApprovalConfig {
sandbox_approval: true,
rules: true,
skill_approval: true,
request_permissions: true,
mcp_elicitations: true,
}),
ApprovalsReviewer::User,
&Policy::empty(),
/*exec_permission_approvals_enabled*/ false,
/*request_permissions_tool_enabled*/ false,
);
assert_eq!(
text,
granular_prompt_expected(
&[
"- `sandbox_approval`",
"- `rules`",
"- `skill_approval`",
"- `mcp_elicitations`",
],
&[],
/*include_shell_permission_request_instructions*/ false,
/*include_request_permissions_tool_section*/ false,
)
);
}
#[test]
fn granular_policy_includes_request_permissions_tool_only_when_that_prompt_can_still_fire() {
let allowed = approval_text(
AskForApproval::Granular(GranularApprovalConfig {
sandbox_approval: true,
rules: true,
skill_approval: true,
request_permissions: true,
mcp_elicitations: true,
}),
ApprovalsReviewer::User,
&Policy::empty(),
/*exec_permission_approvals_enabled*/ true,
/*request_permissions_tool_enabled*/ true,
);
assert!(allowed.contains("# request_permissions Tool"));
let rejected = approval_text(
AskForApproval::Granular(GranularApprovalConfig {
sandbox_approval: true,
rules: true,
skill_approval: true,
request_permissions: false,
mcp_elicitations: true,
}),
ApprovalsReviewer::User,
&Policy::empty(),
/*exec_permission_approvals_enabled*/ true,
/*request_permissions_tool_enabled*/ true,
);
assert!(!rejected.contains("# request_permissions Tool"));
}
#[test]
fn granular_policy_lists_request_permissions_category_without_tool_section_when_tool_unavailable() {
let text = approval_text(
AskForApproval::Granular(GranularApprovalConfig {
sandbox_approval: false,
rules: false,
skill_approval: false,
request_permissions: true,
mcp_elicitations: false,
}),
ApprovalsReviewer::User,
&Policy::empty(),
/*exec_permission_approvals_enabled*/ true,
/*request_permissions_tool_enabled*/ false,
);
assert!(!text.contains("- `request_permissions`"));
assert!(!text.contains("# request_permissions Tool"));
}
@@ -0,0 +1,25 @@
use super::ContextualUserFragment;
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct PersonalitySpecInstructions {
spec: String,
}
impl PersonalitySpecInstructions {
pub(crate) fn new(spec: impl Into<String>) -> Self {
Self { spec: spec.into() }
}
}
impl ContextualUserFragment for PersonalitySpecInstructions {
const ROLE: &'static str = "developer";
const START_MARKER: &'static str = "<personality_spec>";
const END_MARKER: &'static str = "</personality_spec>";
fn body(&self) -> String {
format!(
" The user has requested a new communication style. Future messages should adhere to the following personality: \n{} ",
self.spec
)
}
}
@@ -0,0 +1,22 @@
use super::ContextualUserFragment;
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct PluginInstructions {
text: String,
}
impl PluginInstructions {
pub(crate) fn new(text: impl Into<String>) -> Self {
Self { text: text.into() }
}
}
impl ContextualUserFragment for PluginInstructions {
const ROLE: &'static str = "developer";
const START_MARKER: &'static str = "";
const END_MARKER: &'static str = "";
fn body(&self) -> String {
self.text.clone()
}
}
@@ -0,0 +1,32 @@
use super::ContextualUserFragment;
use codex_protocol::protocol::REALTIME_CONVERSATION_CLOSE_TAG;
use codex_protocol::protocol::REALTIME_CONVERSATION_OPEN_TAG;
const REALTIME_END_INSTRUCTIONS: &str = include_str!("prompts/realtime/realtime_end.md");
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RealtimeEndInstructions {
reason: String,
}
impl RealtimeEndInstructions {
pub(crate) fn new(reason: impl Into<String>) -> Self {
Self {
reason: reason.into(),
}
}
}
impl ContextualUserFragment for RealtimeEndInstructions {
const ROLE: &'static str = "developer";
const START_MARKER: &'static str = REALTIME_CONVERSATION_OPEN_TAG;
const END_MARKER: &'static str = REALTIME_CONVERSATION_CLOSE_TAG;
fn body(&self) -> String {
format!(
"\n{}\n\nReason: {}\n",
REALTIME_END_INSTRUCTIONS.trim(),
self.reason
)
}
}
@@ -0,0 +1,18 @@
use super::ContextualUserFragment;
use codex_protocol::protocol::REALTIME_CONVERSATION_CLOSE_TAG;
use codex_protocol::protocol::REALTIME_CONVERSATION_OPEN_TAG;
const REALTIME_START_INSTRUCTIONS: &str = include_str!("prompts/realtime/realtime_start.md");
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct RealtimeStartInstructions;
impl ContextualUserFragment for RealtimeStartInstructions {
const ROLE: &'static str = "developer";
const START_MARKER: &'static str = REALTIME_CONVERSATION_OPEN_TAG;
const END_MARKER: &'static str = REALTIME_CONVERSATION_CLOSE_TAG;
fn body(&self) -> String {
format!("\n{}\n", REALTIME_START_INSTRUCTIONS.trim())
}
}
@@ -0,0 +1,26 @@
use super::ContextualUserFragment;
use codex_protocol::protocol::REALTIME_CONVERSATION_CLOSE_TAG;
use codex_protocol::protocol::REALTIME_CONVERSATION_OPEN_TAG;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RealtimeStartWithInstructions {
instructions: String,
}
impl RealtimeStartWithInstructions {
pub(crate) fn new(instructions: impl Into<String>) -> Self {
Self {
instructions: instructions.into(),
}
}
}
impl ContextualUserFragment for RealtimeStartWithInstructions {
const ROLE: &'static str = "developer";
const START_MARKER: &'static str = REALTIME_CONVERSATION_OPEN_TAG;
const END_MARKER: &'static str = REALTIME_CONVERSATION_CLOSE_TAG;
fn body(&self) -> String {
format!("\n{}\n", self.instructions)
}
}
@@ -26,7 +26,7 @@ impl ContextualUserFragment for SkillInstructions {
fn body(&self) -> String {
format!(
"\n<name>{}</name>\n<path>{}</path>\n{}",
"\n<name>{}</name>\n<path>{}</path>\n{}\n",
self.name, self.path, self.contents
)
}
@@ -0,0 +1,14 @@
use super::ContextualUserFragment;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct SpawnAgentInstructions;
impl ContextualUserFragment for SpawnAgentInstructions {
const ROLE: &'static str = "developer";
const START_MARKER: &'static str = "<spawned_agent_context>";
const END_MARKER: &'static str = "</spawned_agent_context>";
fn body(&self) -> String {
"\nYou are a newly spawned agent in a team of agents collaborating to complete a task. You can spawn sub-agents to handle subtasks, and those sub-agents can spawn their own sub-agents. You are responsible for returning the response to your assigned task in the final channel. When you give your response, the contents of your response in the final channel will be immediately delivered back to your parent agent. The prior conversation history was forked from your parent agent. Treat the next user message as your assigned task, and use the forked history only as background context.\n".to_string()
}
}
@@ -24,7 +24,7 @@ impl ContextualUserFragment for SubagentNotification {
fn body(&self) -> String {
format!(
"\n{}",
"\n{}\n",
serde_json::json!({
"agent_path": &self.agent_reference,
"status": &self.status,
+1 -1
View File
@@ -21,6 +21,6 @@ impl ContextualUserFragment for TurnAborted {
const END_MARKER: &'static str = "</turn_aborted>";
fn body(&self) -> String {
format!("\n{}", self.guidance)
format!("\n{}\n", self.guidance)
}
}
@@ -12,6 +12,6 @@ impl ContextualUserFragment for UserInstructions {
const END_MARKER: &'static str = "</INSTRUCTIONS>";
fn body(&self) -> String {
format!("{}\n\n<INSTRUCTIONS>\n{}", self.directory, self.text)
format!("{}\n\n<INSTRUCTIONS>\n{}\n", self.directory, self.text)
}
}
@@ -33,7 +33,7 @@ impl ContextualUserFragment for UserShellCommand {
fn body(&self) -> String {
format!(
"\n<command>\n{}\n</command>\n<result>\nExit code: {}\nDuration: {:.4} seconds\nOutput:\n{}\n</result>",
"\n<command>\n{}\n</command>\n<result>\nExit code: {}\nDuration: {:.4} seconds\nOutput:\n{}\n</result>\n",
self.command, self.exit_code, self.duration_seconds, self.output,
)
}
+35 -28
View File
@@ -1,5 +1,12 @@
use crate::context::CollaborationModeInstructions;
use crate::context::ContextualUserFragment;
use crate::context::EnvironmentContext;
use crate::context::ModelSwitchInstructions;
use crate::context::PermissionsInstructions;
use crate::context::PersonalitySpecInstructions;
use crate::context::RealtimeEndInstructions;
use crate::context::RealtimeStartInstructions;
use crate::context::RealtimeStartWithInstructions;
use crate::session::PreviousTurnSettings;
use crate::session::turn_context::TurnContext;
use crate::shell::Shell;
@@ -7,7 +14,6 @@ use codex_execpolicy::Policy;
use codex_features::Feature;
use codex_protocol::config_types::Personality;
use codex_protocol::models::ContentItem;
use codex_protocol::models::DeveloperInstructions;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::protocol::TurnContextItem;
@@ -37,7 +43,7 @@ fn build_permissions_update_item(
previous: Option<&TurnContextItem>,
next: &TurnContext,
exec_policy: &Policy,
) -> Option<DeveloperInstructions> {
) -> Option<String> {
if !next.config.include_permissions_instructions {
return None;
}
@@ -49,28 +55,32 @@ fn build_permissions_update_item(
return None;
}
Some(DeveloperInstructions::from_policy(
next.sandbox_policy.get(),
next.approval_policy.value(),
next.config.approvals_reviewer,
exec_policy,
&next.cwd,
next.features.enabled(Feature::ExecPermissionApprovals),
next.features.enabled(Feature::RequestPermissionsTool),
))
Some(
PermissionsInstructions::from_policy(
next.sandbox_policy.get(),
next.approval_policy.value(),
next.config.approvals_reviewer,
exec_policy,
&next.cwd,
next.features.enabled(Feature::ExecPermissionApprovals),
next.features.enabled(Feature::RequestPermissionsTool),
)
.render(),
)
}
fn build_collaboration_mode_update_item(
previous: Option<&TurnContextItem>,
next: &TurnContext,
) -> Option<DeveloperInstructions> {
) -> Option<String> {
let prev = previous?;
if prev.collaboration_mode.as_ref() != Some(&next.collaboration_mode) {
// If the next mode has empty developer instructions, this returns None and we emit no
// update, so prior collaboration instructions remain in the prompt history.
Some(DeveloperInstructions::from_collaboration_mode(
&next.collaboration_mode,
)?)
Some(
CollaborationModeInstructions::from_collaboration_mode(&next.collaboration_mode)?
.render(),
)
} else {
None
}
@@ -80,28 +90,28 @@ pub(crate) fn build_realtime_update_item(
previous: Option<&TurnContextItem>,
previous_turn_settings: Option<&PreviousTurnSettings>,
next: &TurnContext,
) -> Option<DeveloperInstructions> {
) -> Option<String> {
match (
previous.and_then(|item| item.realtime_active),
next.realtime_active,
) {
(Some(true), false) => Some(DeveloperInstructions::realtime_end_message("inactive")),
(Some(true), false) => Some(RealtimeEndInstructions::new("inactive").render()),
(Some(false), true) | (None, true) => Some(
if let Some(instructions) = next
.config
.experimental_realtime_start_instructions
.as_deref()
{
DeveloperInstructions::realtime_start_message_with_instructions(instructions)
RealtimeStartWithInstructions::new(instructions).render()
} else {
DeveloperInstructions::realtime_start_message()
RealtimeStartInstructions.render()
},
),
(Some(true), true) | (Some(false), false) => None,
(None, false) => previous_turn_settings
.and_then(|settings| settings.realtime_active)
.filter(|realtime_active| *realtime_active)
.map(|_| DeveloperInstructions::realtime_end_message("inactive")),
.map(|_| RealtimeEndInstructions::new("inactive").render()),
}
}
@@ -109,7 +119,7 @@ pub(crate) fn build_initial_realtime_item(
previous: Option<&TurnContextItem>,
previous_turn_settings: Option<&PreviousTurnSettings>,
next: &TurnContext,
) -> Option<DeveloperInstructions> {
) -> Option<String> {
build_realtime_update_item(previous, previous_turn_settings, next)
}
@@ -117,7 +127,7 @@ fn build_personality_update_item(
previous: Option<&TurnContextItem>,
next: &TurnContext,
personality_feature_enabled: bool,
) -> Option<DeveloperInstructions> {
) -> Option<String> {
if !personality_feature_enabled {
return None;
}
@@ -131,7 +141,7 @@ fn build_personality_update_item(
{
let model_info = &next.model_info;
let personality_message = personality_message_for(model_info, personality);
personality_message.map(DeveloperInstructions::personality_spec_message)
personality_message.map(|message| PersonalitySpecInstructions::new(message).render())
} else {
None
}
@@ -151,7 +161,7 @@ pub(crate) fn personality_message_for(
pub(crate) fn build_model_instructions_update_item(
previous_turn_settings: Option<&PreviousTurnSettings>,
next: &TurnContext,
) -> Option<DeveloperInstructions> {
) -> Option<String> {
let previous_turn_settings = previous_turn_settings?;
if previous_turn_settings.model == next.model_info.slug {
return None;
@@ -162,9 +172,7 @@ pub(crate) fn build_model_instructions_update_item(
return None;
}
Some(DeveloperInstructions::model_switch_message(
model_instructions,
))
Some(ModelSwitchInstructions::new(model_instructions).render())
}
pub(crate) fn build_developer_update_item(text_sections: Vec<String>) -> Option<ResponseItem> {
@@ -218,7 +226,6 @@ pub(crate) fn build_settings_update_items(
]
.into_iter()
.flatten()
.map(DeveloperInstructions::into_text)
.collect();
let mut items = Vec::with_capacity(2);
+6 -1
View File
@@ -314,7 +314,11 @@ pub fn build_exec_request(
windows_sandbox_level,
windows_sandbox_private_desktop,
})
.map(|request| ExecRequest::from_sandbox_exec_request(request, options))
.map(|request| {
let windows_sandbox_policy_cwd = AbsolutePathBuf::try_from(sandbox_cwd.to_path_buf())
.unwrap_or_else(|_| request.cwd.clone());
ExecRequest::from_sandbox_exec_request(request, options, windows_sandbox_policy_cwd)
})
.map_err(CodexErr::from)?;
let use_windows_elevated_backend = windows_sandbox_uses_elevated_backend(
exec_req.windows_sandbox_level,
@@ -357,6 +361,7 @@ pub(crate) async fn execute_exec_request(
expiration,
capture_policy,
sandbox,
windows_sandbox_policy_cwd: _,
windows_sandbox_level,
windows_sandbox_private_desktop,
sandbox_policy,
+3 -11
View File
@@ -7,7 +7,6 @@ use std::time::Duration;
use anyhow::anyhow;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
use codex_protocol::models::DeveloperInstructions;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::protocol::AskForApproval;
@@ -29,6 +28,8 @@ use crate::config::Constrained;
use crate::config::ManagedFeatures;
use crate::config::NetworkProxySpec;
use crate::config::Permissions;
use crate::context::ContextualUserFragment;
use crate::context::GuardianFollowupReviewReminder;
use crate::rollout::recorder::RolloutRecorder;
use crate::session::Codex;
use crate::session::session::Session;
@@ -48,14 +49,6 @@ use super::prompt::guardian_policy_prompt;
use super::prompt::guardian_policy_prompt_with_config;
const GUARDIAN_INTERRUPT_DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
const GUARDIAN_FOLLOWUP_REVIEW_REMINDER: &str = concat!(
"Use prior reviews as context, not binding precedent. ",
"Follow the Workspace Policy. ",
"If the user explicitly approves a previously rejected action after being informed of the ",
"concrete risks, set outcome to \"allow\" unless the policy explicitly disallows user ",
"overwrites in such cases."
);
#[derive(Debug)]
pub(crate) enum GuardianReviewSessionOutcome {
Completed(anyhow::Result<Option<String>>),
@@ -634,8 +627,7 @@ async fn run_review_on_session(
async fn append_guardian_followup_reminder(review_session: &GuardianReviewSession) {
let turn_context = review_session.codex.session.new_default_turn().await;
let reminder: ResponseItem =
DeveloperInstructions::new(GUARDIAN_FOLLOWUP_REVIEW_REMINDER).into();
let reminder: ResponseItem = ContextualUserFragment::into(GuardianFollowupReviewReminder);
review_session
.codex
.session
+4 -2
View File
@@ -17,7 +17,6 @@ use codex_hooks::UserPromptSubmitRequest;
use codex_otel::HOOK_RUN_DURATION_METRIC;
use codex_otel::HOOK_RUN_METRIC;
use codex_protocol::items::TurnItem;
use codex_protocol::models::DeveloperInstructions;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::AskForApproval;
@@ -31,6 +30,8 @@ use codex_protocol::protocol::HookStartedEvent;
use codex_protocol::user_input::UserInput;
use serde_json::Value;
use crate::context::ContextualUserFragment;
use crate::context::HookAdditionalContext;
use crate::event_mapping::parse_turn_item;
use crate::session::session::Session;
use crate::session::turn_context::TurnContext;
@@ -340,7 +341,8 @@ pub(crate) async fn record_additional_contexts(
fn additional_context_messages(additional_contexts: Vec<String>) -> Vec<ResponseItem> {
additional_contexts
.into_iter()
.map(|additional_context| DeveloperInstructions::new(additional_context).into())
.map(HookAdditionalContext::new)
.map(ContextualUserFragment::into)
.collect()
}
+2 -3
View File
@@ -27,12 +27,11 @@ mod commit_attribution;
pub mod config;
pub mod config_loader;
pub mod connectors;
mod context;
pub mod context;
mod context_manager;
pub mod exec;
pub mod exec_env;
mod exec_policy;
pub mod external_agent_config;
pub mod file_watcher;
mod flags;
#[cfg(test)]
@@ -93,6 +92,7 @@ pub(crate) use skills::SkillLoadOutcome;
pub(crate) use skills::SkillMetadata;
pub(crate) use skills::SkillsLoadInput;
pub(crate) use skills::SkillsManager;
pub(crate) use skills::build_available_skills;
pub(crate) use skills::build_skill_injections;
pub(crate) use skills::build_skill_name_counts;
pub(crate) use skills::collect_env_var_dependencies;
@@ -101,7 +101,6 @@ pub(crate) use skills::default_skill_metadata_budget;
pub(crate) use skills::injection;
pub(crate) use skills::manager;
pub(crate) use skills::maybe_emit_implicit_skill_invocation;
pub(crate) use skills::render_skills_section;
pub(crate) use skills::resolve_skill_dependencies_for_turn;
pub(crate) use skills::skills_load_input_from_config;
mod skills_watcher;
+27
View File
@@ -477,6 +477,8 @@ async fn execute_mcp_tool_call(
metadata.and_then(|metadata| metadata.openai_file_input_params.as_deref()),
)
.await?;
let request_meta =
with_mcp_tool_call_thread_id_meta(request_meta, &sess.conversation_id.to_string());
let request_meta =
augment_mcp_tool_request_meta_with_sandbox_state(sess, turn_context, server, request_meta)
.await
@@ -660,6 +662,7 @@ pub(crate) struct McpToolApprovalMetadata {
const MCP_TOOL_CODEX_APPS_META_KEY: &str = "_codex_apps";
const MCP_TOOL_OPENAI_OUTPUT_TEMPLATE_META_KEY: &str = "openai/outputTemplate";
const MCP_TOOL_UI_RESOURCE_URI_META_KEY: &str = "ui/resourceUri";
const MCP_TOOL_THREAD_ID_META_KEY: &str = "threadId";
fn custom_mcp_tool_approval_mode(
turn_context: &TurnContext,
@@ -714,6 +717,30 @@ fn build_mcp_tool_call_request_meta(
(!request_meta.is_empty()).then_some(serde_json::Value::Object(request_meta))
}
fn with_mcp_tool_call_thread_id_meta(
meta: Option<serde_json::Value>,
thread_id: &str,
) -> Option<serde_json::Value> {
match meta {
Some(serde_json::Value::Object(mut map)) => {
map.insert(
MCP_TOOL_THREAD_ID_META_KEY.to_string(),
serde_json::Value::String(thread_id.to_string()),
);
Some(serde_json::Value::Object(map))
}
None => {
let mut map = serde_json::Map::new();
map.insert(
MCP_TOOL_THREAD_ID_META_KEY.to_string(),
serde_json::Value::String(thread_id.to_string()),
);
Some(serde_json::Value::Object(map))
}
other => other,
}
}
#[derive(Clone, Copy)]
struct McpToolApprovalPromptOptions {
allow_session_remember: bool,
+29
View File
@@ -651,6 +651,35 @@ async fn codex_apps_tool_call_request_meta_includes_turn_metadata_and_codex_apps
);
}
#[test]
fn mcp_tool_call_thread_id_meta_is_added_to_request_meta() {
assert_eq!(
with_mcp_tool_call_thread_id_meta(
Some(serde_json::json!({
"source": "test-client",
"threadId": "stale-thread",
})),
"thread-live",
),
Some(serde_json::json!({
"source": "test-client",
"threadId": "thread-live",
}))
);
assert_eq!(
with_mcp_tool_call_thread_id_meta(/*meta*/ None, "thread-live"),
Some(serde_json::json!({
"threadId": "thread-live",
}))
);
assert_eq!(
with_mcp_tool_call_thread_id_meta(Some(serde_json::json!("invalid-meta")), "thread-live"),
Some(serde_json::json!("invalid-meta"))
);
}
#[test]
fn accepted_elicitation_content_converts_to_request_user_input_response() {
let response = request_user_input_response_from_elicitation_content(Some(serde_json::json!(
+4 -3
View File
@@ -2,10 +2,11 @@ use std::collections::BTreeSet;
use std::collections::HashMap;
use codex_connectors::metadata::connector_display_label;
use codex_protocol::models::DeveloperInstructions;
use codex_protocol::models::ResponseItem;
use crate::connectors;
use crate::context::ContextualUserFragment;
use crate::context::PluginInstructions;
use crate::plugins::PluginCapabilitySummary;
use crate::plugins::render_explicit_plugin_instructions;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
@@ -52,8 +53,8 @@ pub(crate) fn build_plugin_injections(
.into_iter()
.collect::<Vec<_>>();
render_explicit_plugin_instructions(plugin, &available_mcp_servers, &available_apps)
.map(DeveloperInstructions::new)
.map(ResponseItem::from)
.map(PluginInstructions::new)
.map(ContextualUserFragment::into)
})
.collect()
}

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