diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index fb614e0ac..508cd517d 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2548,6 +2548,7 @@ dependencies = [ "codex-utils-string", "codex-web-search-extension", "codex-windows-sandbox", + "codex-workflows", "core_test_support", "croner", "csv", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 284592fe5..f2d47dc72 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -7221,6 +7221,51 @@ } ] }, + "AgentModelAttestation": { + "properties": { + "agentId": { + "type": "string" + }, + "appliedConfiguration": true, + "appliedModel": { + "type": [ + "string", + "null" + ] + }, + "boundRuntimeModel": { + "type": [ + "string", + "null" + ] + }, + "configFingerprint": { + "type": [ + "string", + "null" + ] + }, + "idempotencyKeySha256": { + "type": [ + "string", + "null" + ] + }, + "requestedConfiguration": true, + "requestedModel": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "agentId", + "requestedConfiguration", + "appliedConfiguration" + ], + "type": "object" + }, "AgentPendingInteraction": { "properties": { "agentId": { @@ -7444,6 +7489,16 @@ "format": "int64", "type": "integer" }, + "modelAttestation": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AgentModelAttestation" + }, + { + "type": "null" + } + ] + }, "parentAgentRunId": { "type": [ "string", @@ -7544,16 +7599,6 @@ "string", "null" ] - }, - "modelAttestation": { - "anyOf": [ - { - "$ref": "#/definitions/v2/AgentModelAttestation" - }, - { - "type": "null" - } - ] } }, "required": [ @@ -26767,51 +26812,6 @@ "okOverridden" ], "type": "string" - }, - "AgentModelAttestation": { - "properties": { - "agentId": { - "type": "string" - }, - "appliedConfiguration": true, - "appliedModel": { - "type": [ - "string", - "null" - ] - }, - "boundRuntimeModel": { - "type": [ - "string", - "null" - ] - }, - "configFingerprint": { - "type": [ - "string", - "null" - ] - }, - "idempotencyKeySha256": { - "type": [ - "string", - "null" - ] - }, - "requestedConfiguration": true, - "requestedModel": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "agentId", - "requestedConfiguration", - "appliedConfiguration" - ], - "type": "object" } } }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 12a8cc6e7..05e534ad7 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -791,6 +791,51 @@ } ] }, + "AgentModelAttestation": { + "properties": { + "agentId": { + "type": "string" + }, + "appliedConfiguration": true, + "appliedModel": { + "type": [ + "string", + "null" + ] + }, + "boundRuntimeModel": { + "type": [ + "string", + "null" + ] + }, + "configFingerprint": { + "type": [ + "string", + "null" + ] + }, + "idempotencyKeySha256": { + "type": [ + "string", + "null" + ] + }, + "requestedConfiguration": true, + "requestedModel": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "agentId", + "requestedConfiguration", + "appliedConfiguration" + ], + "type": "object" + }, "AgentPendingInteraction": { "properties": { "agentId": { @@ -1014,6 +1059,16 @@ "format": "int64", "type": "integer" }, + "modelAttestation": { + "anyOf": [ + { + "$ref": "#/definitions/AgentModelAttestation" + }, + { + "type": "null" + } + ] + }, "parentAgentRunId": { "type": [ "string", @@ -1114,16 +1169,6 @@ "string", "null" ] - }, - "modelAttestation": { - "anyOf": [ - { - "$ref": "#/definitions/AgentModelAttestation" - }, - { - "type": "null" - } - ] } }, "required": [ @@ -24828,51 +24873,6 @@ "okOverridden" ], "type": "string" - }, - "AgentModelAttestation": { - "properties": { - "agentId": { - "type": "string" - }, - "appliedConfiguration": true, - "appliedModel": { - "type": [ - "string", - "null" - ] - }, - "boundRuntimeModel": { - "type": [ - "string", - "null" - ] - }, - "configFingerprint": { - "type": [ - "string", - "null" - ] - }, - "idempotencyKeySha256": { - "type": [ - "string", - "null" - ] - }, - "requestedConfiguration": true, - "requestedModel": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "agentId", - "requestedConfiguration", - "appliedConfiguration" - ], - "type": "object" } }, "title": "CodexAppServerProtocolV2", diff --git a/codex-rs/app-server-protocol/schema/json/v2/WorktreeAttachResponse.json b/codex-rs/app-server-protocol/schema/json/v2/WorktreeAttachResponse.json index 1b37fb8ab..fc5124cec 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/WorktreeAttachResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/WorktreeAttachResponse.json @@ -8,6 +8,51 @@ ], "type": "string" }, + "AgentModelAttestation": { + "properties": { + "agentId": { + "type": "string" + }, + "appliedConfiguration": true, + "appliedModel": { + "type": [ + "string", + "null" + ] + }, + "boundRuntimeModel": { + "type": [ + "string", + "null" + ] + }, + "configFingerprint": { + "type": [ + "string", + "null" + ] + }, + "idempotencyKeySha256": { + "type": [ + "string", + "null" + ] + }, + "requestedConfiguration": true, + "requestedModel": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "agentId", + "requestedConfiguration", + "appliedConfiguration" + ], + "type": "object" + }, "AgentRetentionState": { "enum": [ "active", @@ -133,6 +178,16 @@ "format": "int64", "type": "integer" }, + "modelAttestation": { + "anyOf": [ + { + "$ref": "#/$defs/AgentModelAttestation" + }, + { + "type": "null" + } + ] + }, "parentAgentRunId": { "type": [ "string", @@ -233,16 +288,6 @@ "string", "null" ] - }, - "modelAttestation": { - "anyOf": [ - { - "$ref": "#/$defs/AgentModelAttestation" - }, - { - "type": "null" - } - ] } }, "required": [ @@ -436,51 +481,6 @@ "backgroundAgent" ], "type": "string" - }, - "AgentModelAttestation": { - "properties": { - "agentId": { - "type": "string" - }, - "appliedConfiguration": true, - "appliedModel": { - "type": [ - "string", - "null" - ] - }, - "boundRuntimeModel": { - "type": [ - "string", - "null" - ] - }, - "configFingerprint": { - "type": [ - "string", - "null" - ] - }, - "idempotencyKeySha256": { - "type": [ - "string", - "null" - ] - }, - "requestedConfiguration": true, - "requestedModel": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "agentId", - "requestedConfiguration", - "appliedConfiguration" - ], - "type": "object" } }, "$schema": "https://json-schema.org/draft/2020-12/schema", diff --git a/codex-rs/app-server-protocol/schema/json/v2/WorktreeCleanupResponse.json b/codex-rs/app-server-protocol/schema/json/v2/WorktreeCleanupResponse.json index f48bd1da6..1dde466b2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/WorktreeCleanupResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/WorktreeCleanupResponse.json @@ -8,6 +8,51 @@ ], "type": "string" }, + "AgentModelAttestation": { + "properties": { + "agentId": { + "type": "string" + }, + "appliedConfiguration": true, + "appliedModel": { + "type": [ + "string", + "null" + ] + }, + "boundRuntimeModel": { + "type": [ + "string", + "null" + ] + }, + "configFingerprint": { + "type": [ + "string", + "null" + ] + }, + "idempotencyKeySha256": { + "type": [ + "string", + "null" + ] + }, + "requestedConfiguration": true, + "requestedModel": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "agentId", + "requestedConfiguration", + "appliedConfiguration" + ], + "type": "object" + }, "AgentRetentionState": { "enum": [ "active", @@ -133,6 +178,16 @@ "format": "int64", "type": "integer" }, + "modelAttestation": { + "anyOf": [ + { + "$ref": "#/$defs/AgentModelAttestation" + }, + { + "type": "null" + } + ] + }, "parentAgentRunId": { "type": [ "string", @@ -233,16 +288,6 @@ "string", "null" ] - }, - "modelAttestation": { - "anyOf": [ - { - "$ref": "#/$defs/AgentModelAttestation" - }, - { - "type": "null" - } - ] } }, "required": [ @@ -436,51 +481,6 @@ "backgroundAgent" ], "type": "string" - }, - "AgentModelAttestation": { - "properties": { - "agentId": { - "type": "string" - }, - "appliedConfiguration": true, - "appliedModel": { - "type": [ - "string", - "null" - ] - }, - "boundRuntimeModel": { - "type": [ - "string", - "null" - ] - }, - "configFingerprint": { - "type": [ - "string", - "null" - ] - }, - "idempotencyKeySha256": { - "type": [ - "string", - "null" - ] - }, - "requestedConfiguration": true, - "requestedModel": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "agentId", - "requestedConfiguration", - "appliedConfiguration" - ], - "type": "object" } }, "$schema": "https://json-schema.org/draft/2020-12/schema", diff --git a/codex-rs/app-server-protocol/schema/json/v2/WorktreeCreateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/WorktreeCreateResponse.json index 1aa834d51..a75b8434b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/WorktreeCreateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/WorktreeCreateResponse.json @@ -8,6 +8,51 @@ ], "type": "string" }, + "AgentModelAttestation": { + "properties": { + "agentId": { + "type": "string" + }, + "appliedConfiguration": true, + "appliedModel": { + "type": [ + "string", + "null" + ] + }, + "boundRuntimeModel": { + "type": [ + "string", + "null" + ] + }, + "configFingerprint": { + "type": [ + "string", + "null" + ] + }, + "idempotencyKeySha256": { + "type": [ + "string", + "null" + ] + }, + "requestedConfiguration": true, + "requestedModel": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "agentId", + "requestedConfiguration", + "appliedConfiguration" + ], + "type": "object" + }, "AgentRetentionState": { "enum": [ "active", @@ -133,6 +178,16 @@ "format": "int64", "type": "integer" }, + "modelAttestation": { + "anyOf": [ + { + "$ref": "#/$defs/AgentModelAttestation" + }, + { + "type": "null" + } + ] + }, "parentAgentRunId": { "type": [ "string", @@ -233,16 +288,6 @@ "string", "null" ] - }, - "modelAttestation": { - "anyOf": [ - { - "$ref": "#/$defs/AgentModelAttestation" - }, - { - "type": "null" - } - ] } }, "required": [ @@ -479,51 +524,6 @@ "auto" ], "type": "string" - }, - "AgentModelAttestation": { - "properties": { - "agentId": { - "type": "string" - }, - "appliedConfiguration": true, - "appliedModel": { - "type": [ - "string", - "null" - ] - }, - "boundRuntimeModel": { - "type": [ - "string", - "null" - ] - }, - "configFingerprint": { - "type": [ - "string", - "null" - ] - }, - "idempotencyKeySha256": { - "type": [ - "string", - "null" - ] - }, - "requestedConfiguration": true, - "requestedModel": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "agentId", - "requestedConfiguration", - "appliedConfiguration" - ], - "type": "object" } }, "$schema": "https://json-schema.org/draft/2020-12/schema", diff --git a/codex-rs/app-server-protocol/schema/json/v2/WorktreeDetachResponse.json b/codex-rs/app-server-protocol/schema/json/v2/WorktreeDetachResponse.json index 97bab8e04..8f0308655 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/WorktreeDetachResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/WorktreeDetachResponse.json @@ -8,6 +8,51 @@ ], "type": "string" }, + "AgentModelAttestation": { + "properties": { + "agentId": { + "type": "string" + }, + "appliedConfiguration": true, + "appliedModel": { + "type": [ + "string", + "null" + ] + }, + "boundRuntimeModel": { + "type": [ + "string", + "null" + ] + }, + "configFingerprint": { + "type": [ + "string", + "null" + ] + }, + "idempotencyKeySha256": { + "type": [ + "string", + "null" + ] + }, + "requestedConfiguration": true, + "requestedModel": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "agentId", + "requestedConfiguration", + "appliedConfiguration" + ], + "type": "object" + }, "AgentRetentionState": { "enum": [ "active", @@ -133,6 +178,16 @@ "format": "int64", "type": "integer" }, + "modelAttestation": { + "anyOf": [ + { + "$ref": "#/$defs/AgentModelAttestation" + }, + { + "type": "null" + } + ] + }, "parentAgentRunId": { "type": [ "string", @@ -233,16 +288,6 @@ "string", "null" ] - }, - "modelAttestation": { - "anyOf": [ - { - "$ref": "#/$defs/AgentModelAttestation" - }, - { - "type": "null" - } - ] } }, "required": [ @@ -436,51 +481,6 @@ "backgroundAgent" ], "type": "string" - }, - "AgentModelAttestation": { - "properties": { - "agentId": { - "type": "string" - }, - "appliedConfiguration": true, - "appliedModel": { - "type": [ - "string", - "null" - ] - }, - "boundRuntimeModel": { - "type": [ - "string", - "null" - ] - }, - "configFingerprint": { - "type": [ - "string", - "null" - ] - }, - "idempotencyKeySha256": { - "type": [ - "string", - "null" - ] - }, - "requestedConfiguration": true, - "requestedModel": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "agentId", - "requestedConfiguration", - "appliedConfiguration" - ], - "type": "object" } }, "$schema": "https://json-schema.org/draft/2020-12/schema", diff --git a/codex-rs/app-server-protocol/schema/json/v2/WorktreeListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/WorktreeListResponse.json index 6c3d95e56..15c35a72b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/WorktreeListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/WorktreeListResponse.json @@ -8,6 +8,51 @@ ], "type": "string" }, + "AgentModelAttestation": { + "properties": { + "agentId": { + "type": "string" + }, + "appliedConfiguration": true, + "appliedModel": { + "type": [ + "string", + "null" + ] + }, + "boundRuntimeModel": { + "type": [ + "string", + "null" + ] + }, + "configFingerprint": { + "type": [ + "string", + "null" + ] + }, + "idempotencyKeySha256": { + "type": [ + "string", + "null" + ] + }, + "requestedConfiguration": true, + "requestedModel": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "agentId", + "requestedConfiguration", + "appliedConfiguration" + ], + "type": "object" + }, "AgentRetentionState": { "enum": [ "active", @@ -133,6 +178,16 @@ "format": "int64", "type": "integer" }, + "modelAttestation": { + "anyOf": [ + { + "$ref": "#/$defs/AgentModelAttestation" + }, + { + "type": "null" + } + ] + }, "parentAgentRunId": { "type": [ "string", @@ -233,16 +288,6 @@ "string", "null" ] - }, - "modelAttestation": { - "anyOf": [ - { - "$ref": "#/$defs/AgentModelAttestation" - }, - { - "type": "null" - } - ] } }, "required": [ @@ -479,51 +524,6 @@ "auto" ], "type": "string" - }, - "AgentModelAttestation": { - "properties": { - "agentId": { - "type": "string" - }, - "appliedConfiguration": true, - "appliedModel": { - "type": [ - "string", - "null" - ] - }, - "boundRuntimeModel": { - "type": [ - "string", - "null" - ] - }, - "configFingerprint": { - "type": [ - "string", - "null" - ] - }, - "idempotencyKeySha256": { - "type": [ - "string", - "null" - ] - }, - "requestedConfiguration": true, - "requestedModel": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "agentId", - "requestedConfiguration", - "appliedConfiguration" - ], - "type": "object" } }, "$schema": "https://json-schema.org/draft/2020-12/schema", diff --git a/codex-rs/app-server-protocol/schema/json/v2/WorktreeReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/WorktreeReadResponse.json index 2a612b80e..29fa11732 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/WorktreeReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/WorktreeReadResponse.json @@ -8,6 +8,51 @@ ], "type": "string" }, + "AgentModelAttestation": { + "properties": { + "agentId": { + "type": "string" + }, + "appliedConfiguration": true, + "appliedModel": { + "type": [ + "string", + "null" + ] + }, + "boundRuntimeModel": { + "type": [ + "string", + "null" + ] + }, + "configFingerprint": { + "type": [ + "string", + "null" + ] + }, + "idempotencyKeySha256": { + "type": [ + "string", + "null" + ] + }, + "requestedConfiguration": true, + "requestedModel": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "agentId", + "requestedConfiguration", + "appliedConfiguration" + ], + "type": "object" + }, "AgentRetentionState": { "enum": [ "active", @@ -133,6 +178,16 @@ "format": "int64", "type": "integer" }, + "modelAttestation": { + "anyOf": [ + { + "$ref": "#/$defs/AgentModelAttestation" + }, + { + "type": "null" + } + ] + }, "parentAgentRunId": { "type": [ "string", @@ -233,16 +288,6 @@ "string", "null" ] - }, - "modelAttestation": { - "anyOf": [ - { - "$ref": "#/$defs/AgentModelAttestation" - }, - { - "type": "null" - } - ] } }, "required": [ @@ -479,51 +524,6 @@ "auto" ], "type": "string" - }, - "AgentModelAttestation": { - "properties": { - "agentId": { - "type": "string" - }, - "appliedConfiguration": true, - "appliedModel": { - "type": [ - "string", - "null" - ] - }, - "boundRuntimeModel": { - "type": [ - "string", - "null" - ] - }, - "configFingerprint": { - "type": [ - "string", - "null" - ] - }, - "idempotencyKeySha256": { - "type": [ - "string", - "null" - ] - }, - "requestedConfiguration": true, - "requestedModel": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "agentId", - "requestedConfiguration", - "appliedConfiguration" - ], - "type": "object" } }, "$schema": "https://json-schema.org/draft/2020-12/schema", diff --git a/codex-rs/app-server-protocol/schema/json/v2/WorktreeReconcileResponse.json b/codex-rs/app-server-protocol/schema/json/v2/WorktreeReconcileResponse.json index a3c519c24..11af955a2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/WorktreeReconcileResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/WorktreeReconcileResponse.json @@ -8,6 +8,51 @@ ], "type": "string" }, + "AgentModelAttestation": { + "properties": { + "agentId": { + "type": "string" + }, + "appliedConfiguration": true, + "appliedModel": { + "type": [ + "string", + "null" + ] + }, + "boundRuntimeModel": { + "type": [ + "string", + "null" + ] + }, + "configFingerprint": { + "type": [ + "string", + "null" + ] + }, + "idempotencyKeySha256": { + "type": [ + "string", + "null" + ] + }, + "requestedConfiguration": true, + "requestedModel": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "agentId", + "requestedConfiguration", + "appliedConfiguration" + ], + "type": "object" + }, "AgentRetentionState": { "enum": [ "active", @@ -133,6 +178,16 @@ "format": "int64", "type": "integer" }, + "modelAttestation": { + "anyOf": [ + { + "$ref": "#/$defs/AgentModelAttestation" + }, + { + "type": "null" + } + ] + }, "parentAgentRunId": { "type": [ "string", @@ -233,16 +288,6 @@ "string", "null" ] - }, - "modelAttestation": { - "anyOf": [ - { - "$ref": "#/$defs/AgentModelAttestation" - }, - { - "type": "null" - } - ] } }, "required": [ @@ -479,51 +524,6 @@ "auto" ], "type": "string" - }, - "AgentModelAttestation": { - "properties": { - "agentId": { - "type": "string" - }, - "appliedConfiguration": true, - "appliedModel": { - "type": [ - "string", - "null" - ] - }, - "boundRuntimeModel": { - "type": [ - "string", - "null" - ] - }, - "configFingerprint": { - "type": [ - "string", - "null" - ] - }, - "idempotencyKeySha256": { - "type": [ - "string", - "null" - ] - }, - "requestedConfiguration": true, - "requestedModel": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "agentId", - "requestedConfiguration", - "appliedConfiguration" - ], - "type": "object" } }, "$schema": "https://json-schema.org/draft/2020-12/schema", diff --git a/codex-rs/app-server-protocol/schema/json/v2/WorktreeReleaseResponse.json b/codex-rs/app-server-protocol/schema/json/v2/WorktreeReleaseResponse.json index 4f410c512..ed9d0579c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/WorktreeReleaseResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/WorktreeReleaseResponse.json @@ -8,6 +8,51 @@ ], "type": "string" }, + "AgentModelAttestation": { + "properties": { + "agentId": { + "type": "string" + }, + "appliedConfiguration": true, + "appliedModel": { + "type": [ + "string", + "null" + ] + }, + "boundRuntimeModel": { + "type": [ + "string", + "null" + ] + }, + "configFingerprint": { + "type": [ + "string", + "null" + ] + }, + "idempotencyKeySha256": { + "type": [ + "string", + "null" + ] + }, + "requestedConfiguration": true, + "requestedModel": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "agentId", + "requestedConfiguration", + "appliedConfiguration" + ], + "type": "object" + }, "AgentRetentionState": { "enum": [ "active", @@ -133,6 +178,16 @@ "format": "int64", "type": "integer" }, + "modelAttestation": { + "anyOf": [ + { + "$ref": "#/$defs/AgentModelAttestation" + }, + { + "type": "null" + } + ] + }, "parentAgentRunId": { "type": [ "string", @@ -233,16 +288,6 @@ "string", "null" ] - }, - "modelAttestation": { - "anyOf": [ - { - "$ref": "#/$defs/AgentModelAttestation" - }, - { - "type": "null" - } - ] } }, "required": [ @@ -436,51 +481,6 @@ "backgroundAgent" ], "type": "string" - }, - "AgentModelAttestation": { - "properties": { - "agentId": { - "type": "string" - }, - "appliedConfiguration": true, - "appliedModel": { - "type": [ - "string", - "null" - ] - }, - "boundRuntimeModel": { - "type": [ - "string", - "null" - ] - }, - "configFingerprint": { - "type": [ - "string", - "null" - ] - }, - "idempotencyKeySha256": { - "type": [ - "string", - "null" - ] - }, - "requestedConfiguration": true, - "requestedModel": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "agentId", - "requestedConfiguration", - "appliedConfiguration" - ], - "type": "object" } }, "$schema": "https://json-schema.org/draft/2020-12/schema", diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index cd30f59b9..8626382fa 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1154,6 +1154,8 @@ Use `thread/workflow/get` to read one saved workflow metadata record, or `thread Use `thread/workflow/run/start` to start a saved workflow for the same thread. The response includes sanitized run state and the projected goal plan when projection succeeds. Use run list/get for inspection and pause/resume/cancel for lifecycle control. +Workflow execution treats the saved model route as an immutable admission contract. Before the run, goal projection, worktree, background worker, verifier, or provider process can start, the server checks the configured gateway, provider, model, reasoning effort, optional service tier and auth/approval/permission profiles, worktree mode, context ceiling, and fallback decision against every route constraint. The requested and effective route receipt is persisted with branch execution state and is checked again for retries, recovered workers, descendants, and verifier attempts; a mismatch returns a stable `workflow_route_*` error instead of selecting another route. An `agent/start` request whose `parentAgentRunId` identifies a workflow worker inherits that exact receipt, must use an isolated worktree when the receipt requires one, and is rejected before admission if the configured descendant route differs. Finite `budget_usd` routes require a provider-backed pre-launch credit reservation plus terminal usage readback. The current background-agent provider path has no such controller, so it rejects finite budgets with `workflow_route_credit_ceiling_unavailable` before billed work. Serialized or caller-supplied reservation and usage fields cannot authorize a launch and do not substitute for that controller. + ```json { "method": "thread/workflow/run/start", "id": 36, "params": { "threadId": "thr_123", diff --git a/codex-rs/app-server/src/extensions.rs b/codex-rs/app-server/src/extensions.rs index 7e257fadc..e8dbfb207 100644 --- a/codex-rs/app-server/src/extensions.rs +++ b/codex-rs/app-server/src/extensions.rs @@ -114,6 +114,26 @@ pub(crate) fn workflow_activation_config( codex_workflows_extension::WorkflowActivationConfig { auth_profile_ref: config.selected_auth_profile.clone(), permission_profile: config.permissions.permission_profile().clone(), + route_runtime: codex_workflows::WorkflowRouteRuntime { + model_gateway: Some(config.model_gateway_id.clone()), + provider: Some(config.model_provider_id.clone()), + model: config.model.clone(), + reasoning: config + .model_reasoning_effort + .as_ref() + .map(ToString::to_string), + service_tier: config.service_tier.clone(), + auth_profile: config.selected_auth_profile.clone(), + approval_policy: Some(config.permissions.approval_policy.value().to_string()), + permission_profile: config + .permissions + .active_permission_profile() + .map(|profile| profile.id), + context_window_tokens: config + .model_context_window + .and_then(|tokens| u64::try_from(tokens).ok()), + credit_control: codex_workflows::WorkflowProviderCreditControl::Unavailable, + }, codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(), use_legacy_landlock: config.features.use_legacy_landlock(), windows_sandbox_level: windows_sandbox_level_from_config(config), diff --git a/codex-rs/app-server/src/request_processors/background_agent_live.rs b/codex-rs/app-server/src/request_processors/background_agent_live.rs index b6d7011ba..3eacb1434 100644 --- a/codex-rs/app-server/src/request_processors/background_agent_live.rs +++ b/codex-rs/app-server/src/request_processors/background_agent_live.rs @@ -89,6 +89,7 @@ use codex_background_agent::worker_admission::worker_admission_from_snapshot; use codex_core::NewThread; use codex_core::StartThreadOptions; use codex_core::config::ConfigOverrides; +use codex_core::config::PermissionProfileSnapshot; use codex_core::config::WorktreeCleanupMode as CoreWorktreeCleanupMode; use codex_core::config::WorktreeSessionMode as CoreWorktreeSessionMode; use codex_exec_server::LOCAL_FS; @@ -106,6 +107,7 @@ use codex_git_utils::validate_git_branch_name; use codex_git_utils::worktree_has_commits_after; use codex_protocol::approvals::ElicitationAction; use codex_protocol::config_types::SandboxMode; +use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::EventMsg; @@ -123,6 +125,9 @@ use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; use codex_rollout::StateDbHandle; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_workflows::WorkflowEffectiveModelRoute; +use codex_workflows::WorkflowProviderCreditControl; +use codex_workflows::WorkflowRouteReceipt; use futures::future::join_all; use serde_json::Value; use serde_json::json; @@ -384,6 +389,12 @@ impl ThreadRequestProcessor { })?); } } + let inherited_workflow_route = self + .workflow_descendant_route_receipt( + params.parent_agent_run_id.as_deref(), + managed_worktree.as_ref(), + ) + .await?; let response = self .background_agent_state_processor() .agent_start_inner( @@ -391,6 +402,7 @@ impl ThreadRequestProcessor { managed_worktree .as_ref() .map(|worktree| worktree.worktree_id.as_str()), + inherited_workflow_route.as_ref(), ) .await?; if let Some(worktree) = managed_worktree.as_ref() { @@ -1799,6 +1811,51 @@ impl ThreadRequestProcessor { params.auth_profile_ref = self.config.selected_auth_profile.clone(); } + async fn workflow_descendant_route_receipt( + &self, + parent_agent_run_id: Option<&str>, + managed_worktree: Option<&AgentStartManagedWorktree>, + ) -> Result, JSONRPCErrorError> { + let Some(parent_agent_run_id) = parent_agent_run_id else { + return Ok(None); + }; + let state_db = self + .state_db + .as_ref() + .ok_or_else(|| internal_error("background agent state store is unavailable"))?; + let parent = state_db + .get_background_agent_run(parent_agent_run_id) + .await + .map_err(|err| internal_error(format!("failed to load parent agent run: {err}")))? + .ok_or_else(|| invalid_params("parentAgentRunId does not identify an admitted run"))?; + let snapshot = state_db + .get_background_agent_initial_execution_snapshot(parent_agent_run_id) + .await + .map_err(|err| { + internal_error(format!( + "failed to load parent agent execution snapshot: {err}" + )) + })? + .ok_or_else(|| invalid_params("parentAgentRunId has no initial execution snapshot"))?; + let Some(receipt) = validate_workflow_route_snapshot(&parent, &snapshot) + .map_err(workflow_route_admission_error)? + else { + return Ok(None); + }; + let worktree_mode = workflow_descendant_worktree_mode( + receipt.effective.worktree_mode.as_str(), + managed_worktree.is_some(), + ) + .map_err(workflow_route_admission_error)?; + let effective = + effective_workflow_route_from_config(self.config.as_ref(), &receipt, worktree_mode) + .map_err(workflow_route_admission_error)?; + receipt + .enforce_descendant(&effective) + .map_err(workflow_route_admission_error)?; + Ok(Some(receipt)) + } + fn spawn_background_agent_reconcile(&self, only_run_id: Option) { let Some(context) = self.background_agent_worker_context() else { return; @@ -2070,7 +2127,7 @@ async fn reconcile_background_agent_worker_processes( continue; }; let worker_admission = - match revalidate_background_agent_worker_admission(&context, run.id.as_str()).await { + match revalidate_background_agent_worker_admission(&context, &run).await { Ok(worker_admission) => worker_admission, Err(err) => { fail_claimed_background_agent_worker_process( @@ -2211,15 +2268,19 @@ async fn reconcile_background_agent_worker_processes( async fn revalidate_background_agent_worker_admission( context: &BackgroundAgentProcessSupervisorContext, - run_id: &str, + run: &BackgroundAgentRun, ) -> anyhow::Result> { let snapshot = context .state_db - .get_background_agent_initial_execution_snapshot(run_id) + .get_background_agent_initial_execution_snapshot(run.id.as_str()) .await? .with_context(|| { - format!("background agent `{run_id}` is missing its initial execution context snapshot") + format!( + "background agent `{}` is missing its initial execution context snapshot", + run.id + ) })?; + validate_workflow_route_snapshot(run, &snapshot)?; let Some(admission) = worker_admission_from_snapshot(&snapshot.payload_json)? else { return Ok(None); }; @@ -3732,6 +3793,18 @@ async fn resolve_background_agent_config( .cloned() .map(serde_json::from_value::) .transpose()?; + let active_permission_profile = payload + .and_then(|payload| payload.get("activePermissionProfile")) + .filter(|value| !value.is_null()) + .cloned() + .map(serde_json::from_value::) + .transpose()?; + let permission_profile_snapshot = permission_profile.clone().map(|permission_profile| { + PermissionProfileSnapshot::from_session_snapshot( + permission_profile, + active_permission_profile, + ) + }); let default_permissions = payload .and_then(|payload| payload.get("permissionProfile")) .and_then(Value::as_str) @@ -3786,11 +3859,22 @@ async fn resolve_background_agent_config( .load_with_overrides(request_overrides, config_overrides) .await .map_err(anyhow::Error::from)?; + if let Some(permission_profile_snapshot) = permission_profile_snapshot { + config + .permissions + .replace_permission_profile_from_session_snapshot(permission_profile_snapshot) + .map_err(|err| { + anyhow::anyhow!( + "workflow_route_permission_profile_unavailable: failed to restore the admitted permission profile: {err}" + ) + })?; + } if config.selected_auth_profile != run.auth_profile_ref { anyhow::bail!( "{BACKGROUND_AGENT_ADMISSION_PROFILE_MISMATCH}: loaded worker auth profile does not match the admitted run" ); } + enforce_workflow_route_config(run, &snapshot, &mut config)?; if let Some(retry_at) = background_agent_exact_profile_retry_at(context, &config, run.auth_profile_ref.as_deref()) .await @@ -3884,6 +3968,16 @@ fn validate_background_agent_initial_execution_snapshot( "{BACKGROUND_AGENT_ADMISSION_SCHEMA_MISMATCH}: persisted execution snapshot configuration fingerprint does not match the admitted run" ); } + if let Some(active_permission_profile) = payload.get("activePermissionProfile") + && !active_permission_profile.is_null() + { + serde_json::from_value::(active_permission_profile.clone()) + .map_err(|err| { + anyhow::anyhow!( + "{BACKGROUND_AGENT_ADMISSION_SCHEMA_MISMATCH}: initial execution snapshot field activePermissionProfile is invalid: {err}" + ) + })?; + } if required_string("versionFingerprint")? != BACKGROUND_AGENT_ADMISSION_SCHEMA_VERSION || run.version_fingerprint.as_deref() != Some(BACKGROUND_AGENT_ADMISSION_SCHEMA_VERSION) { @@ -3906,6 +4000,241 @@ fn validate_background_agent_initial_execution_snapshot( Ok(()) } +fn validate_workflow_route_snapshot( + run: &BackgroundAgentRun, + snapshot: &BackgroundAgentExecutionSnapshot, +) -> anyhow::Result> { + if run.source != "workflow" && run.source != "workflow-descendant" { + return Ok(None); + } + let receipt_value = snapshot.payload_json.get("routeReceipt").ok_or_else(|| { + anyhow::anyhow!( + "workflow_route_receipt_missing: workflow execution snapshot has no route receipt" + ) + })?; + let receipt = serde_json::from_value::(receipt_value.clone()) + .map_err(|err| anyhow::anyhow!("workflow_route_receipt_invalid: {err}"))?; + receipt.enforce_provider_attempt(&receipt.effective)?; + if !matches!( + receipt.effective.credit_control, + WorkflowProviderCreditControl::NotRequested + ) { + anyhow::bail!( + "workflow_route_credit_ceiling_unavailable: background-agent provider runtime has no pre-launch credit reservation controller" + ); + } + + let attestation = run.model_attestation.as_ref().ok_or_else(|| { + anyhow::anyhow!( + "workflow_route_receipt_missing: workflow background-agent run has no model attestation" + ) + })?; + if attestation.requested_model.as_deref() != Some(receipt.requested.model.as_str()) + || attestation.applied_model.as_deref() != Some(receipt.effective.model.as_str()) + || attestation.requested_configuration.get("routeReceipt") != Some(receipt_value) + || attestation.applied_configuration.get("routeReceipt") != Some(receipt_value) + { + anyhow::bail!( + "workflow_route_receipt_mismatch: workflow model attestation differs from its execution snapshot" + ); + } + if run.auth_profile_ref.as_deref() != receipt.effective.auth_profile.as_deref() { + anyhow::bail!( + "workflow_route_auth_profile_mismatch: admitted auth profile differs from the immutable route receipt" + ); + } + let payload = snapshot.payload_json.as_object().ok_or_else(|| { + anyhow::anyhow!( + "workflow_route_receipt_invalid: workflow execution snapshot is not an object" + ) + })?; + validate_workflow_permission_profile_snapshot( + payload, + receipt.effective.permission_profile.as_deref(), + )?; + let scalar_matches = |key: &str, expected: Option<&str>| { + payload + .get(key) + .map_or(expected.is_none(), |value| value.as_str() == expected) + }; + if !scalar_matches( + "modelGateway", + Some(receipt.effective.model_gateway.as_str()), + ) || !scalar_matches("provider", Some(receipt.effective.provider.as_str())) + || !scalar_matches("model", Some(receipt.effective.model.as_str())) + || !scalar_matches("reasoning", Some(receipt.effective.reasoning.as_str())) + || !scalar_matches("serviceTier", receipt.effective.service_tier.as_deref()) + || !scalar_matches( + "approvalPolicy", + receipt.effective.approval_policy.as_deref(), + ) + { + anyhow::bail!( + "workflow_route_receipt_mismatch: workflow execution snapshot route fields differ from its immutable receipt" + ); + } + if payload.get("contextCeilingTokens") + != Some(&serde_json::to_value( + receipt.effective.context_ceiling_tokens, + )?) + || payload.get("creditAccounting") + != Some(&serde_json::to_value(receipt.terminal_credit_accounting())?) + { + anyhow::bail!( + "workflow_route_receipt_mismatch: workflow execution snapshot accounting differs from its immutable receipt" + ); + } + let workspace_mode = payload + .get("workspace") + .and_then(|workspace| workspace.get("mode")) + .and_then(Value::as_str); + if workspace_mode != Some(receipt.effective.worktree_mode.as_str()) { + anyhow::bail!( + "workflow_route_worktree_mode_mismatch: execution workspace differs from the immutable route receipt" + ); + } + Ok(Some(receipt)) +} + +fn validate_workflow_permission_profile_snapshot( + payload: &serde_json::Map, + expected_profile_id: Option<&str>, +) -> anyhow::Result<()> { + let active_permission_profile = payload + .get("activePermissionProfile") + .filter(|value| !value.is_null()) + .cloned() + .map(serde_json::from_value::) + .transpose() + .map_err(|err| { + anyhow::anyhow!( + "workflow_route_permission_profile_invalid: persisted active permission profile is invalid: {err}" + ) + })?; + if active_permission_profile + .as_ref() + .map(|profile| profile.id.as_str()) + != expected_profile_id + { + anyhow::bail!( + "workflow_route_permission_profile_mismatch: persisted active permission profile differs from the immutable route receipt" + ); + } + if !payload + .get("permissionProfile") + .is_some_and(is_background_agent_core_permission_profile_value) + { + anyhow::bail!( + "workflow_route_permission_profile_unavailable: persisted compiled permission profile is missing" + ); + } + Ok(()) +} + +fn enforce_workflow_route_config( + run: &BackgroundAgentRun, + snapshot: &BackgroundAgentExecutionSnapshot, + config: &mut codex_core::config::Config, +) -> anyhow::Result<()> { + let Some(receipt) = validate_workflow_route_snapshot(run, snapshot)? else { + return Ok(()); + }; + if let Some(context_ceiling_tokens) = receipt.effective.context_ceiling_tokens { + let context_ceiling_tokens = i64::try_from(context_ceiling_tokens).map_err(|_| { + anyhow::anyhow!( + "workflow_route_context_ceiling_invalid: context ceiling exceeds the runtime range" + ) + })?; + config.model_context_window = Some(context_ceiling_tokens); + config.model_auto_compact_token_limit = config + .model_auto_compact_token_limit + .map(|limit| limit.min(context_ceiling_tokens)); + } + let effective = effective_workflow_route_from_config( + config, + &receipt, + receipt.effective.worktree_mode.as_str(), + )?; + receipt.enforce_provider_attempt(&effective)?; + config.workflow_route_receipt = Some(receipt); + Ok(()) +} + +fn effective_workflow_route_from_config( + config: &codex_core::config::Config, + receipt: &WorkflowRouteReceipt, + worktree_mode: &str, +) -> anyhow::Result { + let configured_context_window = config + .model_context_window + .and_then(|tokens| u64::try_from(tokens).ok()); + if let Some(required_ceiling) = receipt.effective.context_ceiling_tokens + && configured_context_window.is_none_or(|available| available < required_ceiling) + { + anyhow::bail!( + "workflow_route_context_ceiling_unavailable: configured model context is smaller than the inherited ceiling" + ); + } + Ok(WorkflowEffectiveModelRoute { + model_gateway: config.model_gateway_id.clone(), + provider: config.model_provider_id.clone(), + model: config.model.clone().ok_or_else(|| { + anyhow::anyhow!("workflow_route_model_unavailable: loaded worker model is missing") + })?, + reasoning: config + .model_reasoning_effort + .as_ref() + .map(ToString::to_string) + .ok_or_else(|| { + anyhow::anyhow!( + "workflow_route_reasoning_unavailable: loaded worker reasoning is missing" + ) + })?, + service_tier: config.service_tier.clone(), + auth_profile: config.selected_auth_profile.clone(), + approval_policy: Some(config.permissions.approval_policy.value().to_string()), + permission_profile: config + .permissions + .active_permission_profile() + .map(|profile| profile.id), + worktree_mode: worktree_mode.to_string(), + context_ceiling_tokens: receipt.effective.context_ceiling_tokens, + fallback_used: receipt.effective.fallback_used, + credit_control: WorkflowProviderCreditControl::NotRequested, + }) +} + +fn workflow_descendant_worktree_mode( + parent_mode: &str, + requests_isolated_worktree: bool, +) -> anyhow::Result<&str> { + match (parent_mode, requests_isolated_worktree) { + ("isolated_worktree", _) => Ok("isolated_worktree"), + ("shared_repository", false) => Ok("shared_repository"), + ("shared_repository", true) => anyhow::bail!( + "workflow_route_worktree_mode_mismatch: descendant requested an isolated worktree outside the immutable parent route" + ), + _ => anyhow::bail!( + "workflow_route_worktree_mode_invalid: immutable parent route has an unsupported worktree mode" + ), + } +} + +fn workflow_route_admission_error(err: impl std::fmt::Display) -> JSONRPCErrorError { + let message = err.to_string(); + let code = message + .split(':') + .next() + .unwrap_or("workflow_route_invalid") + .to_string(); + let mut error = invalid_request(message); + error.data = Some(json!({ + "errorCode": code, + "retriable": false, + })); + error +} + async fn insert_initial_goal_for_background_thread( state_db: &codex_state::StateRuntime, run_id: &str, @@ -5564,6 +5893,118 @@ mod tests { use std::sync::atomic::Ordering; use tempfile::TempDir; + #[test] + fn workflow_permission_profile_snapshot_keeps_compiled_profile_and_exact_identity() { + let payload = json!({ + "permissionProfile": PermissionProfile::read_only(), + "activePermissionProfile": {"id": ":read-only", "extends": null}, + }); + validate_workflow_permission_profile_snapshot( + payload.as_object().expect("payload should be an object"), + Some(":read-only"), + ) + .expect("matching compiled profile and identity should pass"); + } + + #[test] + fn workflow_permission_profile_snapshot_rejects_missing_or_mismatched_identity() { + let missing_identity = json!({ + "permissionProfile": PermissionProfile::read_only(), + }); + let missing_error = validate_workflow_permission_profile_snapshot( + missing_identity + .as_object() + .expect("payload should be an object"), + Some(":read-only"), + ) + .expect_err("missing identity must fail closed"); + assert!( + missing_error + .to_string() + .contains("workflow_route_permission_profile_mismatch") + ); + + let mismatched_identity = json!({ + "permissionProfile": PermissionProfile::read_only(), + "activePermissionProfile": {"id": ":workspace", "extends": null}, + }); + let mismatch_error = validate_workflow_permission_profile_snapshot( + mismatched_identity + .as_object() + .expect("payload should be an object"), + Some(":read-only"), + ) + .expect_err("mismatched identity must fail closed"); + assert!( + mismatch_error + .to_string() + .contains("workflow_route_permission_profile_mismatch") + ); + + let missing_compiled_profile = json!({ + "activePermissionProfile": {"id": ":read-only", "extends": null}, + }); + let missing_profile_error = validate_workflow_permission_profile_snapshot( + missing_compiled_profile + .as_object() + .expect("payload should be an object"), + Some(":read-only"), + ) + .expect_err("missing compiled profile must fail closed"); + assert!( + missing_profile_error + .to_string() + .contains("workflow_route_permission_profile_unavailable") + ); + } + + #[test] + fn descendant_worktree_mode_keeps_the_canonical_parent_route() { + let shared_parent = workflow_descendant_worktree_mode( + "isolated_worktree", + /*requests_isolated_worktree*/ false, + ) + .expect("a child may share its parent's isolated worktree"); + assert_eq!(shared_parent, "isolated_worktree"); + + let isolated_child = workflow_descendant_worktree_mode( + "isolated_worktree", + /*requests_isolated_worktree*/ true, + ) + .expect("a child may receive another isolated worktree"); + assert_eq!(isolated_child, "isolated_worktree"); + + let shared_child = workflow_descendant_worktree_mode( + "shared_repository", + /*requests_isolated_worktree*/ false, + ) + .expect("a shared-repository child keeps the parent route"); + assert_eq!(shared_child, "shared_repository"); + } + + #[test] + fn descendant_worktree_mode_rejects_downgrade_and_unknown_values() { + let mismatch = workflow_descendant_worktree_mode( + "shared_repository", + /*requests_isolated_worktree*/ true, + ) + .expect_err("a child must not widen the immutable parent route"); + assert!( + mismatch + .to_string() + .contains("workflow_route_worktree_mode_mismatch") + ); + + let invalid = + workflow_descendant_worktree_mode("isolated", /*requests_isolated_worktree*/ true) + .expect_err("background-agent aliases are not workflow route values"); + assert!( + invalid + .to_string() + .contains("workflow_route_worktree_mode_invalid") + ); + } + #[test] fn spawn_receipt_after_seq_never_goes_negative_for_a_fresh_run() { // Regression for the 100%-at-spawn durable-agent failure: a freshly diff --git a/codex-rs/app-server/src/request_processors/background_agent_processor.rs b/codex-rs/app-server/src/request_processors/background_agent_processor.rs index 3da00afb3..dea782166 100644 --- a/codex-rs/app-server/src/request_processors/background_agent_processor.rs +++ b/codex-rs/app-server/src/request_processors/background_agent_processor.rs @@ -95,6 +95,8 @@ use codex_rollout::StateDbHandle; use codex_state::ManagedWorktreeAssignmentTarget; use codex_state::ManagedWorktreeAttachParams; use codex_state::ManagedWorktreeDetachParams; +use codex_workflows::WorkflowRouteReceipt; +use serde_json::Value; use serde_json::json; use sha2::Digest; use sha2::Sha256; @@ -122,6 +124,7 @@ impl BackgroundAgentRequestProcessor { &self, mut params: AgentStartParams, required_managed_worktree_id: Option<&str>, + inherited_workflow_route: Option<&WorkflowRouteReceipt>, ) -> Result { let state_db = self.state_db()?; normalize_agent_start_schema(&mut params)?; @@ -181,7 +184,11 @@ impl BackgroundAgentRequestProcessor { .unwrap_or_else(|| agent_id.clone()); format!("inline:{identity}:prompt") }); - let source = source.unwrap_or_else(|| "app-server".to_string()); + let source = if inherited_workflow_route.is_some() { + "workflow-descendant".to_string() + } else { + source.unwrap_or_else(|| "app-server".to_string()) + }; let thread_store_kind = thread_store_kind.unwrap_or_else(|| "background-agent".to_string()); validate_agent_start_rollout_path( state_db.as_ref(), @@ -201,6 +208,7 @@ impl BackgroundAgentRequestProcessor { initial_goal_objective.as_deref(), auth_profile_ref.as_deref(), execution_context.as_ref(), + inherited_workflow_route, ) .map_err(|err| { internal_error(format!( @@ -219,7 +227,8 @@ impl BackgroundAgentRequestProcessor { required_managed_worktree_id, config_fingerprint: config_fingerprint.as_deref(), version_fingerprint: version_fingerprint.as_deref(), - }); + inherited_workflow_route, + })?; let requested_model = execution_context .as_ref() .and_then(|context| context.model.clone()); @@ -245,15 +254,30 @@ impl BackgroundAgentRequestProcessor { status_reason: Some("queued for background-agent supervisor".to_string()), config_fingerprint: config_fingerprint.clone(), version_fingerprint, - model_attestation: Some(BackgroundAgentModelAttestationCreateParams { - requested_model, - requested_configuration: json!({ - "executionContext": execution_context.as_ref(), - }), - applied_model, - applied_configuration: json!({ - "executionContext": execution_payload, - }), + model_attestation: Some(if let Some(receipt) = inherited_workflow_route { + BackgroundAgentModelAttestationCreateParams { + requested_model: Some(receipt.requested.model.clone()), + requested_configuration: json!({ + "executionContext": &receipt.requested, + "routeReceipt": receipt, + }), + applied_model: Some(receipt.effective.model.clone()), + applied_configuration: json!({ + "executionContext": execution_payload, + "routeReceipt": receipt, + }), + } + } else { + BackgroundAgentModelAttestationCreateParams { + requested_model, + requested_configuration: json!({ + "executionContext": execution_context.as_ref(), + }), + applied_model, + applied_configuration: json!({ + "executionContext": execution_payload, + }), + } }), }; let prompt_sha256 = format!("{:x}", Sha256::digest(prompt.as_bytes())); @@ -1130,12 +1154,13 @@ struct InitialExecutionSnapshotPayloadParams<'a> { required_managed_worktree_id: Option<&'a str>, config_fingerprint: Option<&'a str>, version_fingerprint: Option<&'a str>, + inherited_workflow_route: Option<&'a WorkflowRouteReceipt>, } fn initial_execution_snapshot_payload( params: InitialExecutionSnapshotPayloadParams<'_>, -) -> serde_json::Value { - json!({ +) -> Result { + let mut payload = json!({ "snapshotSource": "agent/start", "cwd": params.cwd, "initialGoalObjective": params.initial_goal_objective, @@ -1191,7 +1216,50 @@ fn initial_execution_snapshot_payload( "packageFingerprint": BACKGROUND_AGENT_RUNTIME_COMPATIBILITY_FINGERPRINT, "recoveryPolicy": params.recovery_policy, "midTurnCrashSemantics": "abort_mid_turn_resume_at_safe_boundary", - }) + }); + if let Some(receipt) = params.inherited_workflow_route { + let payload = payload.as_object_mut().ok_or_else(|| { + internal_error("initial execution snapshot payload must be an object".to_string()) + })?; + payload.insert( + "modelGateway".to_string(), + json!(receipt.effective.model_gateway), + ); + payload.insert("provider".to_string(), json!(receipt.effective.provider)); + payload.insert("model".to_string(), json!(receipt.effective.model)); + payload.insert("reasoning".to_string(), json!(receipt.effective.reasoning)); + payload.insert( + "serviceTier".to_string(), + json!(receipt.effective.service_tier), + ); + payload.insert( + "approvalPolicy".to_string(), + json!(receipt.effective.approval_policy), + ); + payload.insert( + "activePermissionProfile".to_string(), + receipt + .effective + .permission_profile + .as_deref() + .map(|id| json!({"id": id, "extends": null})) + .unwrap_or(Value::Null), + ); + payload.insert("routeReceipt".to_string(), json!(receipt)); + payload.insert( + "contextCeilingTokens".to_string(), + json!(receipt.effective.context_ceiling_tokens), + ); + payload.insert( + "creditAccounting".to_string(), + json!(receipt.terminal_credit_accounting()), + ); + payload.insert( + "workspace".to_string(), + json!({"mode": receipt.effective.worktree_mode}), + ); + } + Ok(payload) } fn background_agent_config_fingerprint( @@ -1199,12 +1267,14 @@ fn background_agent_config_fingerprint( initial_goal_objective: Option<&str>, auth_profile_ref: Option<&str>, execution_context: Option<&AgentExecutionContextParams>, + inherited_workflow_route: Option<&WorkflowRouteReceipt>, ) -> anyhow::Result { let config_identity = json!({ "cwd": cwd, "initialGoalObjective": initial_goal_objective, "authProfileRef": auth_profile_ref, "executionContext": execution_context, + "routeReceipt": inherited_workflow_route, }); Ok(format!( "{:x}", @@ -1832,8 +1902,170 @@ fn api_lifecycle_effect_from_runtime(effect: LifecycleEffect) -> AgentLifecycleE mod tests { use super::*; use chrono::Utc; + use codex_protocol::models::PermissionProfile; + use codex_workflows::WorkflowEffectiveModelRoute; + use codex_workflows::WorkflowModelRoute; + use codex_workflows::WorkflowProviderCreditControl; use tempfile::TempDir; + #[test] + fn descendant_snapshot_persists_the_immutable_parent_route_receipt() { + let receipt = test_workflow_route_receipt(); + let permission_profile = serde_json::to_value(PermissionProfile::read_only()) + .expect("read-only permission profile should serialize"); + let payload = initial_execution_snapshot_payload(InitialExecutionSnapshotPayloadParams { + cwd: Some("/tmp/worktree"), + initial_goal_objective: None, + execution_context: Some(&AgentExecutionContextParams { + workspace_roots: Some(vec!["/tmp/worktree".to_string()]), + approval_policy: None, + permission_profile: Some(permission_profile.clone()), + sandbox_policy: None, + network_policy: None, + model: None, + provider: None, + service_tier: None, + mcp_tool_allowlist: None, + env_snapshot_policy: None, + shell_snapshot: None, + config_source_hashes: None, + max_runtime_seconds: None, + max_tokens: None, + recovery_policy: None, + }), + recovery_policy: "abort_mid_turn_resume_at_safe_boundary", + auth_profile_ref: Some("account007"), + required_managed_worktree_id: Some("worktree-1"), + config_fingerprint: Some("cfg-1"), + version_fingerprint: Some(BACKGROUND_AGENT_ADMISSION_SCHEMA_VERSION), + inherited_workflow_route: Some(&receipt), + }) + .expect("workflow descendant payload should serialize"); + + assert_eq!(payload.get("routeReceipt"), Some(&json!(receipt))); + assert_eq!(payload.get("modelGateway"), Some(&json!("openrouter"))); + assert_eq!(payload.get("reasoning"), Some(&json!("high"))); + assert_eq!(payload.get("permissionProfile"), Some(&permission_profile)); + assert_eq!( + payload.pointer("/activePermissionProfile/id"), + Some(&json!(":read-only")) + ); + assert_eq!( + payload.pointer("/workspace/mode"), + Some(&json!("isolated_worktree")) + ); + assert_eq!( + payload.get("creditAccounting"), + Some(&json!({"status": "not_requested"})) + ); + } + + #[test] + fn ordinary_agent_snapshot_does_not_claim_a_workflow_route_receipt() { + let payload = initial_execution_snapshot_payload(InitialExecutionSnapshotPayloadParams { + cwd: None, + initial_goal_objective: None, + execution_context: None, + recovery_policy: "abort_mid_turn_resume_at_safe_boundary", + auth_profile_ref: None, + required_managed_worktree_id: None, + config_fingerprint: Some("cfg-1"), + version_fingerprint: Some(BACKGROUND_AGENT_ADMISSION_SCHEMA_VERSION), + inherited_workflow_route: None, + }) + .expect("ordinary agent payload should serialize"); + + assert_eq!(payload.get("routeReceipt"), None); + } + + #[tokio::test] + async fn descendant_admission_persists_the_parent_route_and_source() -> anyhow::Result<()> { + let (_temp, state_db) = temp_state_db().await?; + let processor = BackgroundAgentRequestProcessor::new(Some(state_db)); + let receipt = test_workflow_route_receipt(); + let response = processor + .agent_start_inner( + AgentStartParams { + prompt: "continue the workflow child".to_string(), + initial_goal_objective: None, + cwd: Some("/tmp/worktree".to_string()), + idempotency_key: Some("workflow-child-1".to_string()), + request_id: None, + source: Some("caller-supplied".to_string()), + prompt_snapshot_ref: None, + input_snapshot_ref: None, + thread_id: None, + thread_store_kind: None, + thread_store_id: None, + rollout_path: None, + parent_thread_id: None, + parent_agent_run_id: Some("workflow-parent-1".to_string()), + spawn_linkage: None, + auth_profile_ref: Some("account007".to_string()), + config_fingerprint: None, + version_fingerprint: Some( + BACKGROUND_AGENT_ADMISSION_SCHEMA_VERSION.to_string(), + ), + execution_context: None, + }, + Some("worktree-1"), + Some(&receipt), + ) + .await + .expect("workflow descendant admission should succeed"); + + assert_eq!(response.agent.source, "workflow-descendant"); + assert_eq!( + response.agent.parent_agent_run_id.as_deref(), + Some("workflow-parent-1") + ); + let attestation = response + .agent + .model_attestation + .as_ref() + .expect("workflow descendant should persist model attestation"); + assert_eq!( + attestation + .requested_configuration + .pointer("/executionContext/model"), + Some(&json!("openai/gpt-5.6-sol")) + ); + assert_eq!( + response.execution_snapshot.payload.get("routeReceipt"), + Some(&json!(receipt)) + ); + Ok(()) + } + + fn test_workflow_route_receipt() -> WorkflowRouteReceipt { + WorkflowRouteReceipt { + requested: WorkflowModelRoute { + model_gateway: "openrouter".to_string(), + provider: "openrouter".to_string(), + model: "openai/gpt-5.6-sol".to_string(), + reasoning: "high".to_string(), + service_tier: Some("priority".to_string()), + approval_policy: Some("never".to_string()), + permission_profile: Some(":read-only".to_string()), + routing: None, + }, + effective: WorkflowEffectiveModelRoute { + model_gateway: "openrouter".to_string(), + provider: "openrouter".to_string(), + model: "openai/gpt-5.6-sol".to_string(), + reasoning: "high".to_string(), + service_tier: Some("priority".to_string()), + auth_profile: Some("account007".to_string()), + approval_policy: Some("never".to_string()), + permission_profile: Some(":read-only".to_string()), + worktree_mode: "isolated_worktree".to_string(), + context_ceiling_tokens: Some(128_000), + fallback_used: false, + credit_control: WorkflowProviderCreditControl::NotRequested, + }, + } + } + #[tokio::test] async fn agent_stop_with_active_pending_interaction_keeps_snapshot_in_sync() -> anyhow::Result<()> { diff --git a/codex-rs/app-server/tests/suite/v2/workflow.rs b/codex-rs/app-server/tests/suite/v2/workflow.rs index 24176498f..a0b69ba4d 100644 --- a/codex-rs/app-server/tests/suite/v2/workflow.rs +++ b/codex-rs/app-server/tests/suite/v2/workflow.rs @@ -34,6 +34,8 @@ use codex_state::StateRuntime; use pretty_assertions::assert_eq; use serde_json::json; use std::path::Path; +#[cfg(target_os = "linux")] +use std::path::PathBuf; use std::process::Command; use tempfile::TempDir; use tokio::time::sleep; @@ -500,11 +502,12 @@ async fn workflow_start_activates_paired_real_workers_and_verifiers() -> Result< create_final_assistant_message_sse_response("workflow review done")?, ]) .await; - create_config_toml_with_sandbox_mode( + create_config_toml_with_sandbox_mode_and_permission_profile( codex_home.path(), &server.uri(), WorkflowsFeature::Enabled, "workspace-write", + ":workspace", )?; let thread_id = create_materialized_thread_with_cwd( codex_home.path(), @@ -513,6 +516,25 @@ async fn workflow_start_activates_paired_real_workers_and_verifiers() -> Result< )?; codex_workflows::parse_workflow_yaml(&yaml)?; + #[cfg(target_os = "linux")] + let mut mcp = if std::env::var_os("CARGO_BIN_EXE_bwrap").is_some() { + TestAppServer::new_without_managed_config(codex_home.path()).await? + } else { + let bwrap_path = find_system_bwrap()?; + TestAppServer::new_without_managed_config_with_env( + codex_home.path(), + &[( + "CARGO_BIN_EXE_bwrap", + Some( + bwrap_path + .to_str() + .ok_or_else(|| anyhow::anyhow!("bwrap path is not UTF-8"))?, + ), + )], + ) + .await? + }; + #[cfg(not(target_os = "linux"))] let mut mcp = TestAppServer::new_without_managed_config(codex_home.path()).await?; initialize(&mut mcp, ExperimentalApiCapability::Enabled).await?; @@ -551,7 +573,8 @@ async fn workflow_start_activates_paired_real_workers_and_verifiers() -> Result< assert_eq!( codex_state::WorkflowRunStatus::Completed, - snapshot.run.status + snapshot.run.status, + "workflow snapshot: {snapshot:#?}" ); assert_eq!(2, snapshot.steps.len()); assert!( @@ -695,6 +718,24 @@ async fn workflow_start_activates_paired_real_workers_and_verifiers() -> Result< Ok(()) } +#[cfg(target_os = "linux")] +fn find_system_bwrap() -> Result { + use std::os::unix::fs::PermissionsExt; + + let path = std::env::var_os("PATH") + .ok_or_else(|| anyhow::anyhow!("PATH is unavailable while locating bwrap"))?; + for directory in std::env::split_paths(&path) { + let candidate = directory.join("bwrap"); + let Ok(metadata) = candidate.metadata() else { + continue; + }; + if metadata.is_file() && metadata.permissions().mode() & 0o111 != 0 { + return Ok(candidate.canonicalize()?); + } + } + anyhow::bail!("system bwrap is unavailable on PATH") +} + #[tokio::test] async fn workflow_create_returns_sanitized_validation_error() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; @@ -864,14 +905,21 @@ fn create_config_toml( server_uri: &str, workflows_feature: WorkflowsFeature, ) -> std::io::Result<()> { - create_config_toml_with_sandbox_mode(codex_home, server_uri, workflows_feature, "read-only") + create_config_toml_with_sandbox_mode_and_permission_profile( + codex_home, + server_uri, + workflows_feature, + "read-only", + ":read-only", + ) } -fn create_config_toml_with_sandbox_mode( +fn create_config_toml_with_sandbox_mode_and_permission_profile( codex_home: &Path, server_uri: &str, workflows_feature: WorkflowsFeature, sandbox_mode: &str, + permission_profile: &str, ) -> std::io::Result<()> { let config_toml = codex_home.join("config.toml"); std::fs::write( @@ -879,8 +927,10 @@ fn create_config_toml_with_sandbox_mode( format!( r#" model = "mock-model" +model_reasoning_effort = "high" approval_policy = "never" sandbox_mode = "{sandbox_mode}" +default_permissions = "{permission_profile}" model_provider = "mock_provider" suppress_unstable_features_warning = true @@ -985,7 +1035,7 @@ execution_defaults: model: "mock-model" reasoning: "high" approval_policy: "never" - permission_profile: "read-only" + permission_profile: ":workspace" limits: max_parallel_steps: 1 max_agents: 2 @@ -1006,7 +1056,7 @@ agents: model: "mock-model" reasoning: "high" approval_policy: "never" - permission_profile: "read-only" + permission_profile: ":workspace" - id: "adversarial_reviewer" display_name: "Reviewer-Hypatia" role: "Independently review the exact activation candidate." @@ -1016,7 +1066,7 @@ agents: model: "mock-model" reasoning: "high" approval_policy: "never" - permission_profile: "workspace-write" + permission_profile: ":workspace" steps: - id: "actual_worker" title: "Run the actual workflow worker" @@ -1027,7 +1077,7 @@ steps: model: "mock-model" reasoning: "high" approval_policy: "never" - permission_profile: "read-only" + permission_profile: ":workspace" workspace: mode: "isolated_worktree" depends_on: [] @@ -1054,7 +1104,7 @@ steps: model: "mock-model" reasoning: "high" approval_policy: "never" - permission_profile: "workspace-write" + permission_profile: ":workspace" workspace: mode: "isolated_worktree" depends_on: @@ -1096,8 +1146,8 @@ source_prompt: "Build a serious workflow without leaking {RAW_SENTINEL}" status: "draft" execution_defaults: model_gateway: "hasna" - provider: "openai" - model: "gpt-5.4" + provider: "mock_provider" + model: "mock-model" reasoning: "high" limits: max_parallel_steps: 2 @@ -1115,24 +1165,24 @@ agents: role: "Own the architecture and implementation map." model: model_gateway: "hasna" - provider: "openai" - model: "gpt-5.4" + provider: "mock_provider" + model: "mock-model" reasoning: "high" - id: "adversarial_security" display_name: "Adversary-Hypatia" role: "Adversarially attack the security and leakage assumptions." model: model_gateway: "hasna" - provider: "openai" - model: "gpt-5.4" + provider: "mock_provider" + model: "mock-model" reasoning: "high" - id: "adversarial_testing" display_name: "Adversary-Euclid" role: "Adversarially attack the deterministic test evidence." model: model_gateway: "hasna" - provider: "openai" - model: "gpt-5.4" + provider: "mock_provider" + model: "mock-model" reasoning: "high" steps: - id: "design" @@ -1140,8 +1190,8 @@ steps: agent: "architect" model: model_gateway: "hasna" - provider: "openai" - model: "gpt-5.4" + provider: "mock_provider" + model: "mock-model" reasoning: "high" depends_on: [] outputs: @@ -1161,8 +1211,8 @@ steps: parallel_group: "adversarial_review" model: model_gateway: "hasna" - provider: "openai" - model: "gpt-5.4" + provider: "mock_provider" + model: "mock-model" reasoning: "high" depends_on: - "design" @@ -1183,8 +1233,8 @@ steps: parallel_group: "adversarial_review" model: model_gateway: "hasna" - provider: "openai" - model: "gpt-5.4" + provider: "mock_provider" + model: "mock-model" reasoning: "high" depends_on: - "design" diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 0c2c6d704..dcc6707be 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -68,6 +68,7 @@ codex-state = { workspace = true } codex-terminal-detection = { workspace = true } codex-thread-store = { workspace = true } codex-tools = { workspace = true } +codex-workflows = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-cache = { workspace = true } codex-utils-image = { workspace = true } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index dac269fd4..edb46e9d8 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -5867,4 +5867,4 @@ }, "title": "ConfigToml", "type": "object" -} +} \ No newline at end of file diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index d08dfeb3b..b1ad86e88 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -88,6 +88,7 @@ use codex_rollout_trace::CompactionTraceContext; use codex_rollout_trace::InferenceTraceAttempt; use codex_rollout_trace::InferenceTraceContext; use codex_tools::create_tools_json_for_responses_api; +use codex_workflows::WorkflowRouteReceipt; use eventsource_stream::Event; use eventsource_stream::EventStreamError; use futures::StreamExt; @@ -180,6 +181,7 @@ struct ModelClientState { window_generation: AtomicU64, installation_id: String, provider: SharedModelProvider, + provider_id: String, auth_env_telemetry: AuthEnvTelemetry, session_source: SessionSource, parent_thread_id: Option, @@ -229,6 +231,7 @@ impl RequestRouteTelemetry { pub struct ModelClient { state: Arc, prompt_cache_key_override: Option, + workflow_route_receipt: Option, } /// A turn-scoped streaming session created from a [`ModelClient`]. @@ -410,7 +413,7 @@ impl ModelClient { attestation_provider: Option>, ) -> Self { let model_provider = - create_model_provider_with_id(provider_id, provider_info, auth_manager); + create_model_provider_with_id(provider_id.clone(), provider_info, auth_manager); let codex_api_key_env_enabled = model_provider .auth_manager() .as_ref() @@ -425,6 +428,7 @@ impl ModelClient { window_generation: AtomicU64::new(0), installation_id, provider: model_provider, + provider_id, auth_env_telemetry, session_source, parent_thread_id, @@ -438,6 +442,7 @@ impl ModelClient { cached_websocket_session: StdMutex::new(WebsocketSession::default()), }), prompt_cache_key_override: None, + workflow_route_receipt: None, } } @@ -449,6 +454,43 @@ impl ModelClient { self } + pub(crate) fn with_workflow_route_receipt( + mut self, + workflow_route_receipt: Option, + ) -> Self { + self.workflow_route_receipt = workflow_route_receipt; + self + } + + fn enforce_workflow_provider_attempt( + &self, + model_info: &ModelInfo, + effort: Option<&ReasoningEffortConfig>, + service_tier: Option<&str>, + ) -> Result<()> { + let Some(receipt) = self.workflow_route_receipt.as_ref() else { + return Ok(()); + }; + let mut effective = receipt.effective.clone(); + effective.provider = self.state.provider_id.clone(); + effective.model = model_info.slug.clone(); + effective.auth_profile = self + .state + .provider + .auth_manager() + .and_then(|manager| manager.selected_auth_profile()); + effective.reasoning = effort.map(ToString::to_string).ok_or_else(|| { + CodexErr::InvalidRequest( + "workflow_route_reasoning_unavailable: provider request has no reasoning effort" + .to_string(), + ) + })?; + effective.service_tier = service_tier.map(str::to_string); + receipt + .enforce_provider_attempt(&effective) + .map_err(|error| CodexErr::InvalidRequest(error.to_string())) + } + fn prompt_cache_key(&self) -> String { self.prompt_cache_key_override .clone() @@ -560,6 +602,11 @@ impl ModelClient { if prompt.input.is_empty() { return Ok(Vec::new()); } + self.enforce_workflow_provider_attempt( + model_info, + settings.effort.as_ref(), + settings.service_tier.as_deref(), + )?; let client_setup = self.current_client_setup().await?; let transport = ReqwestTransport::new(build_reqwest_client()); let request_telemetry = @@ -653,6 +700,12 @@ impl ModelClient { ) -> Result { // Create the media call over HTTP first, then retain matching auth so realtime can attach // the server-side control WebSocket to the call id from that HTTP response. + if self.workflow_route_receipt.is_some() { + return Err(CodexErr::UnsupportedOperation( + "workflow_route_provider_call_unsupported: realtime calls are not part of the admitted workflow route" + .to_string(), + )); + } let client_setup = self.current_client_setup().await?; if let Some(header_value) = self.generate_attestation_header_for().await { extra_headers.insert(X_OAI_ATTESTATION_HEADER, header_value); @@ -691,6 +744,12 @@ impl ModelClient { return Ok(Vec::new()); } + self.enforce_workflow_provider_attempt( + model_info, + effort.as_ref(), + /*service_tier*/ None, + )?; + let client_setup = self.current_client_setup().await?; let transport = ReqwestTransport::new(build_reqwest_client()); let request_telemetry = Self::build_request_telemetry( @@ -1299,6 +1358,12 @@ impl ModelClientSession { if !self.responses_websocket_enabled() { return Ok(()); } + // A preconnect has neither reasoning nor service-tier inputs, so it cannot prove the + // immutable workflow route. Defer the connection until the first real request, where the + // complete route is enforced immediately before the provider attempt. + if self.client.workflow_route_receipt.is_some() { + return Ok(()); + } if self.websocket_session.connection.is_some() { return Ok(()); } @@ -1452,6 +1517,11 @@ impl ModelClientSession { .map(AuthManager::unauthorized_recovery); let mut pending_retry = PendingUnauthorizedRetry::default(); loop { + self.client.enforce_workflow_provider_attempt( + model_info, + effort.as_ref(), + service_tier.as_deref(), + )?; ensure_remote_compaction_request_budget_available(request_budget)?; let client_setup = self.client.current_client_setup().await?; let transport = ReqwestTransport::new(build_reqwest_client()); @@ -1577,6 +1647,11 @@ impl ModelClientSession { .map(AuthManager::unauthorized_recovery); let mut pending_retry = PendingUnauthorizedRetry::default(); loop { + self.client.enforce_workflow_provider_attempt( + model_info, + effort.as_ref(), + service_tier.as_deref(), + )?; ensure_remote_compaction_request_budget_available(request_budget)?; let client_setup = self.client.current_client_setup().await?; let transport = ReqwestTransport::new(build_reqwest_client()); @@ -1706,6 +1781,11 @@ impl ModelClientSession { .map(AuthManager::unauthorized_recovery); let mut pending_retry = PendingUnauthorizedRetry::default(); loop { + self.client.enforce_workflow_provider_attempt( + model_info, + effort.as_ref(), + service_tier.as_deref(), + )?; let client_setup = self.client.current_client_setup().await?; let request_auth_context = AuthRequestTelemetryContext::new( client_setup.auth.as_ref().map(CodexAuth::auth_mode), diff --git a/codex-rs/core/src/client_tests.rs b/codex-rs/core/src/client_tests.rs index 124a4a60b..cb90866f1 100644 --- a/codex-rs/core/src/client_tests.rs +++ b/codex-rs/core/src/client_tests.rs @@ -30,6 +30,7 @@ use codex_protocol::config_types::ReasoningSummary; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelInfo; +use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::InternalSessionSource; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; @@ -47,6 +48,10 @@ use codex_tools::ResponsesApiNamespaceTool; use codex_tools::ResponsesApiTool; use codex_tools::ToolSpec; use codex_tools::ZaiWebSearchConfig; +use codex_workflows::WorkflowEffectiveModelRoute; +use codex_workflows::WorkflowModelRoute; +use codex_workflows::WorkflowProviderCreditControl; +use codex_workflows::WorkflowRouteReceipt; use futures::StreamExt; use pretty_assertions::assert_eq; use serde_json::json; @@ -148,6 +153,67 @@ fn test_model_info() -> ModelInfo { .expect("deserialize test model info") } +fn workflow_route_receipt_for_client(client: &ModelClient) -> WorkflowRouteReceipt { + let provider = client.state.provider_id.clone(); + let requested = WorkflowModelRoute { + model_gateway: "direct".to_string(), + provider: provider.clone(), + model: "gpt-test".to_string(), + reasoning: "medium".to_string(), + service_tier: None, + approval_policy: None, + permission_profile: None, + routing: None, + }; + WorkflowRouteReceipt { + requested, + effective: WorkflowEffectiveModelRoute { + model_gateway: "direct".to_string(), + provider, + model: "gpt-test".to_string(), + reasoning: "medium".to_string(), + service_tier: None, + auth_profile: None, + approval_policy: None, + permission_profile: None, + worktree_mode: "shared_repository".to_string(), + context_ceiling_tokens: None, + fallback_used: false, + credit_control: WorkflowProviderCreditControl::NotRequested, + }, + } +} + +#[test] +fn workflow_provider_call_guard_is_two_sided_and_fails_before_call() { + let client = test_model_client(SessionSource::Exec); + let receipt = workflow_route_receipt_for_client(&client); + let client = client.with_workflow_route_receipt(Some(receipt)); + let provider_calls = AtomicUsize::new(0); + + client + .enforce_workflow_provider_attempt( + &test_model_info(), + Some(&ReasoningEffort::Medium), + /*service_tier*/ None, + ) + .expect("the exact admitted request must pass"); + provider_calls.fetch_add(1, Ordering::SeqCst); + assert_eq!(provider_calls.load(Ordering::SeqCst), 1); + + let mut mismatched_model = test_model_info(); + mismatched_model.slug = "gpt-other".to_string(); + let error = client + .enforce_workflow_provider_attempt( + &mismatched_model, + Some(&ReasoningEffort::Medium), + /*service_tier*/ None, + ) + .expect_err("a mismatched provider request must fail before the call"); + assert!(error.to_string().contains("workflow_route_model_mismatch")); + assert_eq!(provider_calls.load(Ordering::SeqCst), 1); +} + fn test_session_telemetry() -> SessionTelemetry { SessionTelemetry::new( ThreadId::new(), diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 964bd5337..a2a9fffa0 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1242,6 +1242,10 @@ pub struct Config { /// Info needed to make an API request to the model. pub model_provider: ModelProviderInfo, + /// Immutable workflow route contract for provider-call enforcement. + /// Set only by the admitted workflow worker path after snapshot revalidation. + pub workflow_route_receipt: Option, + /// Optionally specify the personality of the model pub personality: Option, @@ -4654,6 +4658,7 @@ impl Config { model_provider_id, model_gateway_id, model_provider, + workflow_route_receipt: None, cwd: resolved_cwd, workspace_roots: workspace_roots.clone(), workspace_roots_explicit, diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index ca47b23fc..9071ca8c0 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -1089,6 +1089,7 @@ impl Session { Self::build_model_client_beta_features_header(config), self.services.attestation_provider.clone(), ) + .with_workflow_route_receipt(config.workflow_route_receipt.clone()) .with_prompt_cache_key_override( crate::guardian::prompt_cache_key_override_for_review_session( &session_configuration.session_source, diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 6e3c9d971..e77138556 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -222,6 +222,13 @@ impl SessionConfiguration { } pub(crate) fn apply(&self, updates: &SessionSettingsUpdate) -> ConstraintResult { + if self + .original_config_do_not_use + .workflow_route_receipt + .is_some() + { + validate_immutable_workflow_route_settings(updates)?; + } let mut next_configuration = self.clone(); let current_sandbox_policy = self.sandbox_policy(); let current_file_system_sandbox_policy = self.file_system_sandbox_policy(); @@ -500,6 +507,38 @@ impl SessionConfiguration { } } +pub(super) fn validate_immutable_workflow_route_settings( + updates: &SessionSettingsUpdate, +) -> ConstraintResult<()> { + let route_affecting_update = updates.collaboration_mode.is_some() + || updates.worktree_mode.is_some() + || updates.service_tier.is_some() + || updates.model_provider_id.is_some() + || updates.auth_profile.is_some() + || updates.auth_profile_auto_switch_enabled.is_some() + || updates.approval_policy.is_some() + || updates.approvals_reviewer.is_some() + || updates.permission_profile.is_some() + || updates.active_permission_profile.is_some() + || updates.sandbox_policy.is_some() + || updates.windows_sandbox_level.is_some() + || updates.cwd.is_some() + || updates.workspace_roots.is_some() + || updates.profile_workspace_roots.is_some(); + if route_affecting_update { + Err(ConstraintError::InvalidValue { + field_name: "workflow_route", + candidate: "workflow_route_immutable".to_string(), + allowed: + "the exact admitted workflow route; route-affecting session updates are disabled" + .to_string(), + requirement_source: codex_config::RequirementSource::Unknown, + }) + } else { + Ok(()) + } +} + fn apply_model_provider_id( next_configuration: &mut SessionConfiguration, model_provider_id: &str, @@ -1204,6 +1243,7 @@ impl Session { Self::build_model_client_beta_features_header(config.as_ref()), attestation_provider, ) + .with_workflow_route_receipt(config.workflow_route_receipt.clone()) .with_prompt_cache_key_override( crate::guardian::prompt_cache_key_override_for_review_session( &session_configuration.session_source, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 712a398a3..ff713f5a6 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -4663,6 +4663,23 @@ async fn session_settings_null_service_tier_update_uses_default_service_tier() { ); } +#[test] +fn immutable_workflow_route_settings_guard_is_two_sided() { + super::session::validate_immutable_workflow_route_settings(&SessionSettingsUpdate { + session_prompt: Some(Some("allowed non-route update".to_string())), + ..Default::default() + }) + .expect("non-route session settings remain available"); + + let error = + super::session::validate_immutable_workflow_route_settings(&SessionSettingsUpdate { + auth_profile: Some(Some("alternate-profile".to_string())), + ..Default::default() + }) + .expect_err("an admitted workflow auth profile must be immutable"); + assert!(error.to_string().contains("workflow_route_immutable")); +} + #[tokio::test] async fn session_settings_legacy_fast_service_tier_update_uses_priority_request_value() { let session_configuration = make_session_configuration_for_tests().await; diff --git a/codex-rs/ext/workflows/src/activation.rs b/codex-rs/ext/workflows/src/activation.rs index f0095ad4d..220ba3a76 100644 --- a/codex-rs/ext/workflows/src/activation.rs +++ b/codex-rs/ext/workflows/src/activation.rs @@ -44,12 +44,21 @@ use codex_state::WorkflowRunVerifierRecordResultParams; use codex_state::WorkflowRunVerifierResultSummary; use codex_state::busy_retry::retry_on_busy; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_workflows::WorkflowModelRoute; +use codex_workflows::WorkflowProviderCreditControl; +use codex_workflows::WorkflowRouteReceipt; +use codex_workflows::WorkflowRouteRuntime; use codex_workflows::WorkflowVerifier; +use codex_workflows::WorkflowWorkspaceMode; +use codex_workflows::admit_workflow_model_route_for_runtime; +use codex_workflows::parse_workflow_yaml; use codex_workflows::verifier_has_finite_review_artifact_contract; use serde::Deserialize; use serde::Serialize; use sha2::Digest; use sha2::Sha256; +#[cfg(target_os = "linux")] +use std::os::unix::fs::PermissionsExt; use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; @@ -105,10 +114,28 @@ fn verifier_sandbox_support_env() -> HashMap { fn verifier_sandbox_support_env_from( mut read_var: impl FnMut(&str) -> Option, ) -> HashMap { - VERIFIER_SANDBOX_SUPPORT_ENV_VARS + let mut env: HashMap = VERIFIER_SANDBOX_SUPPORT_ENV_VARS .into_iter() .filter_map(|key| read_var(key).map(|value| (key.to_string(), value))) - .collect() + .collect(); + if let Some(bwrap_path) = env.get("CARGO_BIN_EXE_bwrap").map(PathBuf::from) + && bwrap_path.is_absolute() + && bwrap_path.file_name().is_some_and(|name| name == "bwrap") + && bwrap_path + .metadata() + .is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0) + && let Some(parent) = bwrap_path.parent() + { + let mut path_entries = read_var("PATH") + .map(|path| std::env::split_paths(&path).collect::>()) + .unwrap_or_default(); + path_entries.retain(|entry| entry != parent); + path_entries.insert(0, parent.to_path_buf()); + if let Ok(path) = std::env::join_paths(path_entries) { + env.insert("PATH".to_string(), path.to_string_lossy().into_owned()); + } + } + env } #[cfg(not(target_os = "linux"))] @@ -182,6 +209,7 @@ where pub struct WorkflowActivationConfig { pub auth_profile_ref: Option, pub permission_profile: PermissionProfile, + pub route_runtime: WorkflowRouteRuntime, pub codex_linux_sandbox_exe: Option, pub use_legacy_landlock: bool, pub windows_sandbox_level: WindowsSandboxLevel, @@ -194,6 +222,18 @@ impl Default for WorkflowActivationConfig { Self { auth_profile_ref: None, permission_profile: PermissionProfile::read_only(), + route_runtime: WorkflowRouteRuntime { + model_gateway: None, + provider: None, + model: None, + reasoning: None, + service_tier: None, + auth_profile: None, + approval_policy: None, + permission_profile: None, + context_window_tokens: None, + credit_control: WorkflowProviderCreditControl::Unavailable, + }, codex_linux_sandbox_exe: None, use_legacy_landlock: false, windows_sandbox_level: WindowsSandboxLevel::Disabled, @@ -237,6 +277,14 @@ impl WorkflowActivationService { &self, request: WorkflowStartRequest, ) -> anyhow::Result { + let spec_record = self + .state_db + .workflows() + .get_workflow_spec(request.workflow_record_id.as_str()) + .await? + .ok_or_else(|| anyhow::anyhow!("workflow spec record not found"))?; + let spec = parse_workflow_yaml(spec_record.source_yaml.as_str())?; + validate_workflow_routes_before_effects(&spec, &request.activation_config.route_runtime)?; let create_params = WorkflowRunCreateParams { workflow_record_id: request.workflow_record_id, source_thread_id: Some(request.source_thread_id), @@ -317,6 +365,17 @@ impl WorkflowActivationService { .await { Ok(()) => break, + Err(err) + if err + .downcast_ref::() + .is_some() => + { + tracing::warn!( + workflow_run_id = %run_id, + "workflow activation rejected a non-retriable model route: {err}" + ); + break; + } Err(err) => { tracing::warn!( workflow_run_id = %run_id, @@ -350,6 +409,14 @@ impl WorkflowActivationService { tokio::time::sleep(WORKFLOW_SUPERVISOR_POLL_INTERVAL).await; continue; } + let spec_record = self + .state_db + .workflows() + .get_workflow_spec(snapshot.run.workflow_record_id.as_str()) + .await? + .ok_or_else(|| anyhow::anyhow!("workflow spec record not found"))?; + let spec = parse_workflow_yaml(spec_record.source_yaml.as_str())?; + validate_workflow_routes_before_effects(&spec, &config.route_runtime)?; let claim_params = WorkflowRunClaimParams { run_id: run_id.to_string(), @@ -446,6 +513,7 @@ impl WorkflowActivationService { BACKGROUND_AGENT_RUNTIME_COMPATIBILITY_FINGERPRINT.to_string(), ), permission_profile_json: permission_profile_json.clone(), + route_runtime: config.route_runtime.clone(), parent_agent_run_id: None, max_active_background_agent_runs: config.max_active_background_agent_runs, }; @@ -566,6 +634,22 @@ impl WorkflowActivationService { .await; } let expected_exit_code = definition.expected_exit_code.unwrap_or(0); + if let Err(err) = validate_verifier_route(&claimed, &config.route_runtime) { + tracing::warn!( + workflow_run_id = %run_id, + verifier_run_id = %claimed.verifier.verifier_run_id, + "workflow verifier route is not admitted: {err}" + ); + return self + .record_failed_verifier_setup( + run_id, + generation, + claimed, + started, + Some(expected_exit_code), + ) + .await; + } let execution_root = match verifier_execution_root(&claimed) { Ok(execution_root) => execution_root, Err(err) => { @@ -654,6 +738,15 @@ impl WorkflowActivationService { let _ = heartbeat.await; return Ok(false); } + if let Err(err) = validate_verifier_route(&claimed, &config.route_runtime) { + tracing::warn!( + workflow_run_id = %run_id, + verifier_run_id = %claimed.verifier.verifier_run_id, + "workflow verifier route changed before command execution: {err}" + ); + passed = false; + break; + } command_count = command_count.saturating_add(1); let output = process_exec_tool_call( ExecParams { @@ -1330,21 +1423,90 @@ fn yaml_mapping_satisfies_requirement(mapping: &serde_yaml::Mapping, requirement serde_yaml::from_str::(expected).is_ok_and(|expected| expected == *actual) } +fn validate_verifier_route( + claimed: &WorkflowRunVerifierClaimOutcome, + runtime: &WorkflowRouteRuntime, +) -> anyhow::Result { + let requested_value = claimed.step.model_route_json.as_ref().ok_or_else(|| { + anyhow::anyhow!("workflow_route_receipt_missing: verifier step has no model route") + })?; + let requested = + serde_json::from_value::(workflow_state_data(requested_value).clone())?; + let admitted_value = claimed + .step + .branch_admission_json + .as_ref() + .and_then(|admission| workflow_state_data(admission).get("routeReceipt")) + .ok_or_else(|| { + anyhow::anyhow!( + "workflow_route_receipt_missing: verifier step has no admitted route receipt" + ) + })?; + let admitted = serde_json::from_value::(admitted_value.clone())?; + let worktree_mode = match claimed + .step + .workspace_json + .as_ref() + .map(workflow_state_data) + .and_then(|workspace| workspace.get("mode")) + .and_then(serde_json::Value::as_str) + { + Some("isolated_worktree") => "isolated_worktree", + Some("shared_repository") => "shared_repository", + Some(other) => anyhow::bail!( + "workflow_route_worktree_mode_invalid: unsupported verifier worktree mode `{other}`" + ), + None => "shared_repository", + }; + let current = admit_workflow_model_route_for_runtime(&requested, runtime, worktree_mode)?; + if current != admitted { + anyhow::bail!( + "workflow_route_receipt_mismatch: verifier route differs from branch admission" + ); + } + admitted.enforce_provider_attempt(¤t.effective)?; + Ok(admitted) +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct WorkflowActivationFingerprint<'a> { auth_profile_ref: Option<&'a str>, + route_runtime: &'a WorkflowRouteRuntime, admission_schema: &'static str, } fn activation_config_fingerprint(config: &WorkflowActivationConfig) -> anyhow::Result { let value = WorkflowActivationFingerprint { auth_profile_ref: config.auth_profile_ref.as_deref(), + route_runtime: &config.route_runtime, admission_schema: BACKGROUND_AGENT_ADMISSION_SCHEMA_VERSION, }; Ok(format!("{:x}", Sha256::digest(serde_json::to_vec(&value)?))) } +fn validate_workflow_routes_before_effects( + spec: &codex_workflows::WorkflowSpec, + runtime: &WorkflowRouteRuntime, +) -> anyhow::Result<()> { + for step in &spec.steps { + let route = step.model.as_ref().unwrap_or(&spec.execution_defaults); + let workspace_mode = step + .workspace + .as_ref() + .map_or(WorkflowWorkspaceMode::SharedRepository, |workspace| { + workspace.mode + }); + let worktree_mode = match workspace_mode { + WorkflowWorkspaceMode::IsolatedWorktree => "isolated_worktree", + WorkflowWorkspaceMode::SharedRepository => "shared_repository", + }; + admit_workflow_model_route_for_runtime(route, runtime, worktree_mode) + .map_err(anyhow::Error::new)?; + } + Ok(()) +} + fn workflow_state_data(value: &serde_json::Value) -> &serde_json::Value { value.get("data").unwrap_or(value) } @@ -1445,6 +1607,157 @@ mod tests { use std::sync::Arc; use std::sync::atomic::AtomicUsize; + const ROUTE_ACTIVATION_WORKFLOW_YAML: &str = r#" +schema_version: "workflow.codex.codewith/v0" +workflow_id: "wf_route_activation" +display_name: "Route activation" +source_prompt: "Prove route admission happens before workflow effects." +status: "draft" +execution_defaults: + model_gateway: "hasna" + provider: "openai" + model: "gpt-5.4" + reasoning: "high" +limits: + max_parallel_steps: 1 + max_agents: 2 + max_worktrees: 1 + max_runtime_seconds: 300 + max_step_runtime_seconds: 120 + max_tokens: 1000 + max_tool_calls: 10 +approvals: + required_before: [] +agents: + - id: "candidate_worker" + display_name: "Builder-Vitruvius" + role: "Build the exact route activation candidate." + model: + model_gateway: "hasna" + provider: "openai" + model: "gpt-5.4" + reasoning: "high" + - id: "adversarial_reviewer" + display_name: "Reviewer-Hypatia" + role: "Independently review the route activation candidate." + model: + model_gateway: "hasna" + provider: "openai" + model: "gpt-5.4" + reasoning: "high" +steps: + - id: "run" + title: "Run exact route" + agent: "candidate_worker" + model: + model_gateway: "hasna" + provider: "openai" + model: "gpt-5.4" + reasoning: "high" + depends_on: [] + outputs: + - "result.md" + completion: + model_marked_state: "candidate_succeeded" + verifiers: + - id: "result_present" + type: "artifact_contains" + artifact: "result.md" + must_contain: + - "done" + - id: "initial_adversarial_review" + title: "Run the initial adversarial review" + agent: "adversarial_reviewer" + model: + model_gateway: "hasna" + provider: "openai" + model: "gpt-5.4" + reasoning: "high" + depends_on: + - "run" + outputs: + - "review.yaml" + completion: + model_marked_state: "candidate_succeeded" + verifiers: + - id: "finite_review_artifact_contract" + type: "artifact_contains" + artifact: "review.yaml" + must_contain: + - "candidate_identity:" + - "acceptance_criteria:" + - "verdict:" + - "blocking_p0_p1:" + - "non_blocking_p2_p3:" + - "remediation_cycle:" + - "remediation_cycle_cap: 2" +artifacts: + retention: "until_workflow_complete" + required: + - "result.md" + - "review.yaml" +cleanup: + on_cancel: [] + on_complete: [] +"#; + + fn supported_route_runtime() -> WorkflowRouteRuntime { + WorkflowRouteRuntime { + model_gateway: Some("hasna".to_string()), + provider: Some("openai".to_string()), + model: Some("gpt-5.4".to_string()), + reasoning: Some("high".to_string()), + service_tier: None, + auth_profile: None, + approval_policy: Some("never".to_string()), + permission_profile: Some("read-only".to_string()), + context_window_tokens: Some(256_000), + credit_control: WorkflowProviderCreditControl::Unavailable, + } + } + + #[test] + fn activation_route_gate_accepts_supported_exact_route_and_rejects_before_effects() { + let spec = parse_workflow_yaml(ROUTE_ACTIVATION_WORKFLOW_YAML) + .expect("activation workflow should parse"); + let supported = supported_route_runtime(); + + validate_workflow_routes_before_effects(&spec, &supported) + .expect("exact supported route should reach activation"); + + let mut unavailable = supported.clone(); + unavailable.provider = None; + let unavailable_error = validate_workflow_routes_before_effects(&spec, &unavailable) + .expect_err("missing provider must fail before activation effects"); + assert!( + unavailable_error + .to_string() + .contains("workflow_route_provider_unavailable") + ); + assert!( + unavailable_error + .downcast_ref::() + .is_some(), + "route failures must stay typed so restart supervisors do not retry them" + ); + + let mut mismatched = supported; + mismatched.model = Some("gpt-5.3".to_string()); + let mismatch_error = validate_workflow_routes_before_effects(&spec, &mismatched) + .expect_err("mismatched model must fail before activation effects"); + assert!( + mismatch_error + .to_string() + .contains("workflow_route_model_mismatch") + ); + assert!( + mismatch_error + .downcast_ref::() + .is_some(), + "route mismatches must stay typed so restart supervisors do not retry them" + ); + } + #[test] fn workflow_state_operation_labels_cover_every_activation_boundary() { assert_eq!( @@ -1493,11 +1806,21 @@ mod tests { #[cfg(target_os = "linux")] #[test] - fn verifier_sandbox_support_env_forwards_only_bazel_bwrap_runtime_paths() { + fn verifier_sandbox_support_env_forwards_bazel_runtime_and_command_paths() { + use std::fs; + + let temp_dir = tempfile::tempdir().expect("create bwrap directory"); + let bwrap_path = temp_dir.path().join("bwrap"); + fs::write(&bwrap_path, "#!/bin/sh\nexit 0\n").expect("write bwrap fixture"); + let mut permissions = fs::metadata(&bwrap_path) + .expect("read bwrap fixture metadata") + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&bwrap_path, permissions).expect("make bwrap fixture executable"); let ambient = HashMap::from([ ( "CARGO_BIN_EXE_bwrap".to_string(), - "/tmp/bazel-bin/codex-rs/bwrap/bwrap".to_string(), + bwrap_path.to_string_lossy().into_owned(), ), ( "RUNFILES_DIR".to_string(), @@ -1516,15 +1839,24 @@ mod tests { "UNRELATED_ENV".to_string(), "must-not-reach-verifier".to_string(), ), + ("PATH".to_string(), "/trusted/tooling:/usr/bin".to_string()), ]); let env = verifier_sandbox_support_env_from(|key| ambient.get(key).cloned()); + let expected_path = std::env::join_paths([ + temp_dir.path().to_path_buf(), + PathBuf::from("/trusted/tooling"), + PathBuf::from("/usr/bin"), + ]) + .expect("join expected verifier command path") + .to_string_lossy() + .into_owned(); assert_eq!( HashMap::from([ ( "CARGO_BIN_EXE_bwrap".to_string(), - "/tmp/bazel-bin/codex-rs/bwrap/bwrap".to_string(), + bwrap_path.to_string_lossy().into_owned(), ), ( "RUNFILES_DIR".to_string(), @@ -1539,6 +1871,7 @@ mod tests { "/tmp/app-server.runfiles/MANIFEST".to_string(), ), ("TEST_WORKSPACE".to_string(), "_main".to_string()), + ("PATH".to_string(), expected_path), ]), env ); diff --git a/codex-rs/ext/workflows/src/manager_tool.rs b/codex-rs/ext/workflows/src/manager_tool.rs index 41d81ab04..c07af3d57 100644 --- a/codex-rs/ext/workflows/src/manager_tool.rs +++ b/codex-rs/ext/workflows/src/manager_tool.rs @@ -39,7 +39,7 @@ enum ManageWorkflowRuntime { state_db: Arc, thread_id: ThreadId, activation_service: Arc, - activation_config: WorkflowActivationConfig, + activation_config: Box, }, Unavailable { reason: &'static str, @@ -54,12 +54,27 @@ impl ManageWorkflowTool { thread_id: ThreadId, ) -> Self { let activation_service = Arc::new(WorkflowActivationService::new(Arc::clone(&state_db))); + let activation_config = WorkflowActivationConfig { + route_runtime: codex_workflows::WorkflowRouteRuntime { + model_gateway: Some("hasna".to_string()), + provider: Some("openai".to_string()), + model: Some("gpt-5.4".to_string()), + reasoning: Some("high".to_string()), + service_tier: None, + auth_profile: None, + approval_policy: Some("never".to_string()), + permission_profile: Some("read-only".to_string()), + context_window_tokens: Some(256_000), + credit_control: codex_workflows::WorkflowProviderCreditControl::Unavailable, + }, + ..WorkflowActivationConfig::default() + }; Self::new_with_activation( enabled, state_db, thread_id, activation_service, - WorkflowActivationConfig::default(), + activation_config, ) } @@ -76,7 +91,7 @@ impl ManageWorkflowTool { state_db, thread_id, activation_service, - activation_config, + activation_config: Box::new(activation_config), }, } } @@ -626,7 +641,6 @@ mod tests { use codex_extension_api::ToolExecutor; use codex_extension_api::ToolPayload; use codex_extension_api::ToolSpec; - use codex_prompts::DENTAL_LEAD_SAAS_WORKFLOW_EXAMPLE_YAML; use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::ResponseInputItem; use codex_tools::ToolExposure; @@ -638,6 +652,112 @@ mod tests { use super::MANAGE_WORKFLOW_TOOL_NAME; use super::ManageWorkflowTool; + const MANAGE_WORKFLOW_TEST_YAML: &str = r#" +schema_version: "workflow.codex.codewith/v0" +workflow_id: "wf_manage_workflow_test" +display_name: "Manage Workflow Test" +source_prompt: "Exercise workflow lifecycle operations without external effects." +status: "draft" +execution_defaults: + model_gateway: "hasna" + provider: "openai" + model: "gpt-5.4" + reasoning: "high" + approval_policy: "never" + permission_profile: "read-only" +limits: + max_parallel_steps: 1 + max_agents: 2 + max_worktrees: 1 + max_runtime_seconds: 300 + max_step_runtime_seconds: 120 + max_tokens: 1000 + max_tool_calls: 10 +approvals: + required_before: + - "production_deploy" +agents: + - id: "candidate_operator" + display_name: "Builder-Vitruvius" + role: "Exercise the workflow lifecycle gate." + model: + model_gateway: "hasna" + provider: "openai" + model: "gpt-5.4" + reasoning: "high" + approval_policy: "never" + permission_profile: "read-only" + - id: "adversarial_reviewer" + display_name: "Reviewer-Hypatia" + role: "Independently review the workflow lifecycle candidate." + model: + model_gateway: "hasna" + provider: "openai" + model: "gpt-5.4" + reasoning: "high" + approval_policy: "never" + permission_profile: "read-only" +steps: + - id: "launch_gate" + title: "Hold at the explicit launch gate" + agent: "candidate_operator" + model: + model_gateway: "hasna" + provider: "openai" + model: "gpt-5.4" + reasoning: "high" + approval_policy: "never" + permission_profile: "read-only" + approval_gate: "production_deploy" + depends_on: [] + outputs: + - "result.md" + completion: + model_marked_state: "candidate_succeeded" + verifiers: + - id: "result_present" + type: "artifact_contains" + artifact: "result.md" + must_contain: + - "done" + - id: "initial_adversarial_review" + title: "Run the initial adversarial review" + agent: "adversarial_reviewer" + model: + model_gateway: "hasna" + provider: "openai" + model: "gpt-5.4" + reasoning: "high" + approval_policy: "never" + permission_profile: "read-only" + depends_on: + - "launch_gate" + outputs: + - "review.yaml" + completion: + model_marked_state: "candidate_succeeded" + verifiers: + - id: "finite_review_artifact_contract" + type: "artifact_contains" + artifact: "review.yaml" + must_contain: + - "candidate_identity:" + - "acceptance_criteria:" + - "verdict:" + - "blocking_p0_p1:" + - "non_blocking_p2_p3:" + - "remediation_cycle:" + - "remediation_cycle_cap: 2" +artifacts: + retention: "until_workflow_complete" + required: + - "result.md" + - "review.yaml" +cleanup: + on_cancel: [] + on_complete: [] +"#; + #[tokio::test] async fn manage_workflow_lifecycle_returns_sanitized_state() { let tempdir = tempfile::tempdir().expect("tempdir"); @@ -661,8 +781,7 @@ mod tests { .expect("thread metadata should insert"); let tool = ManageWorkflowTool::new(Arc::new(AtomicBool::new(true)), state_db.clone(), thread_id); - let unbudgeted_yaml = - DENTAL_LEAD_SAAS_WORKFLOW_EXAMPLE_YAML.replace(" max_tokens: 2500000", ""); + let unbudgeted_yaml = MANAGE_WORKFLOW_TEST_YAML.replace(" max_tokens: 1000", ""); assert!( !unbudgeted_yaml.contains("max_tokens:"), "unbudgeted workflow fixture must not retain the token limit key" @@ -825,7 +944,7 @@ mod tests { &tool, json!({ "action": "create", - "yaml": DENTAL_LEAD_SAAS_WORKFLOW_EXAMPLE_YAML, + "yaml": MANAGE_WORKFLOW_TEST_YAML, }), ) .await; @@ -846,7 +965,7 @@ mod tests { .expect("run id") .to_string(); - // Approving the dental example's gated launch step records an approved + // Approving the fixture's gated launch step records an approved // decision without leaking the raw reason. let approve = call_tool( &tool, diff --git a/codex-rs/state/src/runtime/workflow_orchestrator.rs b/codex-rs/state/src/runtime/workflow_orchestrator.rs index 51bbd1faf..02e2c8f7c 100644 --- a/codex-rs/state/src/runtime/workflow_orchestrator.rs +++ b/codex-rs/state/src/runtime/workflow_orchestrator.rs @@ -1,4 +1,5 @@ use super::*; +use crate::BackgroundAgentModelAttestationCreateParams; use crate::runtime::background_agents::ExistingBackgroundAgentAdmissionIdentity; use crate::runtime::background_agents::append_background_agent_event_in_tx; use crate::runtime::background_agents::background_agent_admission_identity_sha256; @@ -23,9 +24,14 @@ use codex_git_utils::remove_linked_git_worktree; use codex_git_utils::resolve_git_ref; use codex_protocol::models::PermissionProfile; use codex_workflows::WorkflowBranchPrompt; +use codex_workflows::WorkflowModelRoute; +use codex_workflows::WorkflowRouteEnforcementError; +use codex_workflows::WorkflowRouteReceipt; +use codex_workflows::WorkflowRouteRuntime; use codex_workflows::WorkflowVerifier; use codex_workflows::WorkflowWorkspace; use codex_workflows::WorkflowWorkspaceMode; +use codex_workflows::admit_workflow_model_route_for_runtime; use codex_workflows::render_workflow_branch_prompt; use codex_workflows::verifier_has_finite_review_artifact_contract; use serde_json::Value; @@ -109,6 +115,7 @@ pub struct WorkflowRunBranchAdmissionParams { pub version_fingerprint: Option, pub runtime_package_fingerprint: Option, pub permission_profile_json: Value, + pub route_runtime: WorkflowRouteRuntime, pub parent_agent_run_id: Option, pub max_active_background_agent_runs: Option, } @@ -123,6 +130,7 @@ pub struct WorkflowRunBranchAdmission { pub idempotency_key: String, pub model_route_json: Value, pub workspace_json: Option, + pub route_receipt: WorkflowRouteReceipt, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -634,6 +642,8 @@ async fn admit_ready_workflow_branches_in_tx( provisioned_git_worktrees: &mut Vec, now_ms: i64, ) -> anyhow::Result { + let mut route_runtime = params.route_runtime.clone(); + route_runtime.auth_profile = params.auth_profile_ref.clone(); let limits = WorkflowBranchLimits::from_run(run)?; let active_counts = active_workflow_branch_counts_in_tx(tx, run.run_id.as_str()).await?; let mut capacity = limits @@ -658,6 +668,29 @@ async fn admit_ready_workflow_branches_in_tx( let mut changed = false; for candidate in &candidates { let model_route_json = branch_model_route_json(run, candidate.model_route_json.as_ref())?; + let workspace_json = optional_workflow_state_data(candidate.workspace_json.as_ref())?; + let route_receipt = workflow_branch_route_receipt( + &model_route_json, + workspace_json.as_ref(), + &route_runtime, + ); + if let Err(error) = route_receipt { + changed |= block_workflow_branch_provider_preflight_in_tx( + tx, + run, + params, + candidate, + &model_route_json, + &WorkflowBranchProviderPreflightBlock { + env_key: None, + reason: error.to_string(), + reason_code: error.code().to_string(), + }, + now_ms, + ) + .await?; + continue; + } let Some(preflight_block) = workflow_branch_provider_preflight_block(&model_route_json, provider_env_key_present) else { @@ -691,6 +724,11 @@ async fn admit_ready_workflow_branches_in_tx( let model_route_json = branch_model_route_json(run, candidate.model_route_json.as_ref())?; let workspace_json = optional_workflow_state_data(candidate.workspace_json.as_ref())?; let workspace_mode = workflow_workspace_mode(workspace_json.as_ref())?; + let route_receipt = workflow_branch_route_receipt( + &model_route_json, + workspace_json.as_ref(), + &route_runtime, + )?; if workspace_mode == WorkflowWorkspaceMode::IsolatedWorktree { if isolated_worktree_count >= limits.max_worktrees { continue; @@ -745,6 +783,8 @@ async fn admit_ready_workflow_branches_in_tx( "attempt": branch_attempt, "admittedAtMs": now_ms, "route": workflow_branch_route_summary(&model_route_json), + "routeReceipt": &route_receipt, + "terminalCreditAccounting": route_receipt.terminal_credit_accounting(), "workspace": workspace_json, "managedWorktreeId": provisioned_workspace.worktree_id.as_str(), "cwd": provisioned_workspace.execution_cwd, @@ -795,7 +835,7 @@ WHERE step_run_id = ? run, candidate: &candidate, attempt: branch_attempt, - model_route_json: &model_route_json, + route_receipt: &route_receipt, workspace_json: workspace_json.as_ref(), provisioned_workspace: &provisioned_workspace, review_context: review_context.as_ref(), @@ -822,6 +862,7 @@ WHERE step_run_id = ? "backgroundAgentRunId": background_agent_run_id, "createdBackgroundAgentRun": created, "route": workflow_branch_route_summary(&model_route_json), + "routeReceipt": &route_receipt, "workspaceMode": provisioned_workspace.mode, "managedWorktreeId": provisioned_workspace.worktree_id.as_str(), }), @@ -838,6 +879,7 @@ WHERE step_run_id = ? idempotency_key, model_route_json, workspace_json, + route_receipt, }); } Ok(WorkflowRunBranchAdmissionTxOutcome { @@ -1068,16 +1110,16 @@ struct ReadyBranchCandidate { } struct WorkflowBranchProviderPreflightBlock { - env_key: &'static str, - reason: &'static str, - reason_code: &'static str, + env_key: Option<&'static str>, + reason: String, + reason_code: String, } struct BackgroundBranchRunCreate<'a> { run: &'a crate::WorkflowRun, candidate: &'a ReadyBranchCandidate, attempt: i64, - model_route_json: &'a Value, + route_receipt: &'a WorkflowRouteReceipt, workspace_json: Option<&'a Value>, provisioned_workspace: &'a ProvisionedWorkflowWorkspace, review_context: Option<&'a WorkflowReviewBranchContext>, @@ -1234,8 +1276,8 @@ WHERE step_run_id = ? "#, ) .bind(crate::WorkflowRunStepStatus::Blocked.as_str()) - .bind(preflight_block.reason) - .bind(preflight_block.reason_code) + .bind(preflight_block.reason.as_str()) + .bind(preflight_block.reason_code.as_str()) .bind(now_ms) .bind(candidate.step_run_id.as_str()) .execute(&mut **tx) @@ -1260,8 +1302,8 @@ WHERE run_id = ? "#, ) .bind(crate::WorkflowRunStepVerifierStatus::Blocked.as_str()) - .bind(preflight_block.reason) - .bind(preflight_block.reason_code) + .bind(preflight_block.reason.as_str()) + .bind(preflight_block.reason_code.as_str()) .bind(now_ms) .bind(now_ms) .bind(run.run_id.as_str()) @@ -1282,7 +1324,7 @@ WHERE run_id = ? "stepId": candidate.step_id.as_str(), "agentId": candidate.agent_id.as_str(), "missingEnvKey": preflight_block.env_key, - "reasonCode": preflight_block.reason_code, + "reasonCode": preflight_block.reason_code.as_str(), "route": workflow_branch_route_summary(model_route_json), }), now_ms, @@ -1307,9 +1349,9 @@ fn workflow_branch_provider_preflight_block( } Some(WorkflowBranchProviderPreflightBlock { - env_key: OPENROUTER_API_KEY_ENV_VAR, - reason: WORKFLOW_BRANCH_PROVIDER_ENV_MISSING_REASON, - reason_code: WORKFLOW_BRANCH_PROVIDER_ENV_MISSING_REASON_CODE, + env_key: Some(OPENROUTER_API_KEY_ENV_VAR), + reason: WORKFLOW_BRANCH_PROVIDER_ENV_MISSING_REASON.to_string(), + reason_code: WORKFLOW_BRANCH_PROVIDER_ENV_MISSING_REASON_CODE.to_string(), }) } @@ -1338,12 +1380,14 @@ async fn create_background_branch_run_if_missing_in_tx( tx: &mut sqlx::Transaction<'_, Sqlite>, branch: BackgroundBranchRunCreate<'_>, ) -> anyhow::Result { + let recovery_policy = WORKFLOW_BRANCH_RECOVERY_POLICY; + let execution_payload = branch_execution_payload(&branch, recovery_policy); let BackgroundBranchRunCreate { run, candidate, attempt, - model_route_json, - workspace_json, + route_receipt, + workspace_json: _, provisioned_workspace, review_context, background_agent_run_id, @@ -1396,26 +1440,28 @@ async fn create_background_branch_run_if_missing_in_tx( status_reason: Some("queued by workflow branch admission".to_string()), config_fingerprint: params.config_fingerprint.clone(), version_fingerprint: params.version_fingerprint.clone(), - model_attestation: None, + model_attestation: Some(BackgroundAgentModelAttestationCreateParams { + requested_model: Some(route_receipt.requested.model.clone()), + requested_configuration: json!({ + "executionContext": &route_receipt.requested, + "routeReceipt": route_receipt, + }), + applied_model: Some(route_receipt.effective.model.clone()), + applied_configuration: json!({ + "executionContext": &execution_payload, + "routeReceipt": route_receipt, + }), + }), }; let start_event_payload = json!({ "cwd": provisioned_workspace.execution_cwd.to_string_lossy(), "prompt": prompt, "promptSnapshotRef": prompt_snapshot_ref, }); - let recovery_policy = WORKFLOW_BRANCH_RECOVERY_POLICY; let execution_snapshot_params = BackgroundAgentExecutionSnapshotParams { run_id: background_agent_run_id.to_string(), snapshot_kind: "initial_execution_context".to_string(), - payload_json: branch_execution_payload( - run, - candidate, - model_route_json, - workspace_json, - provisioned_workspace, - review_context, - params, - ), + payload_json: execution_payload, recovery_policy: recovery_policy.to_string(), config_fingerprint: params.config_fingerprint.clone(), }; @@ -1808,14 +1854,14 @@ fn workflow_review_branch_context( } fn branch_execution_payload( - run: &crate::WorkflowRun, - candidate: &ReadyBranchCandidate, - model_route_json: &Value, - workspace_json: Option<&Value>, - provisioned_workspace: &ProvisionedWorkflowWorkspace, - review_context: Option<&WorkflowReviewBranchContext>, - params: &WorkflowRunBranchAdmissionParams, + branch: &BackgroundBranchRunCreate<'_>, + recovery_policy: &str, ) -> Value { + let run = branch.run; + let candidate = branch.candidate; + let route_receipt = branch.route_receipt; + let provisioned_workspace = branch.provisioned_workspace; + let params = branch.params; json!({ "snapshotSource": "workflow/branch_admission", "workflowRunId": run.run_id.as_str(), @@ -1825,28 +1871,57 @@ fn branch_execution_payload( "cwd": provisioned_workspace.execution_cwd.to_string_lossy(), "workspaceRoots": [provisioned_workspace.worktree_path.to_string_lossy()], "managedWorktreeId": provisioned_workspace.worktree_id.as_str(), - "modelGateway": model_route_json.get("model_gateway"), - "model": model_route_json.get("model"), - "provider": model_route_json.get("provider"), - "reasoning": model_route_json.get("reasoning"), - "serviceTier": model_route_json.get("service_tier"), - "approvalPolicy": model_route_json.get("approval_policy"), + "modelGateway": route_receipt.effective.model_gateway.as_str(), + "model": route_receipt.effective.model.as_str(), + "provider": route_receipt.effective.provider.as_str(), + "reasoning": route_receipt.effective.reasoning.as_str(), + "serviceTier": route_receipt.effective.service_tier.as_deref(), + "approvalPolicy": route_receipt.effective.approval_policy.as_deref(), "permissionProfile": params.permission_profile_json.clone(), + "activePermissionProfile": route_receipt + .effective + .permission_profile + .as_deref() + .map(|id| json!({"id": id, "extends": null})), "authProfileIdentitySha256": params .auth_profile_ref .as_deref() .map(|profile| StateRuntime::background_agent_identity_sha256(profile.as_bytes())), - "workspace": workspace_json, - "reviewContext": review_context.map(|context| &context.state_json), + "workspace": branch.workspace_json, + "reviewContext": branch.review_context.map(|context| &context.state_json), "envSnapshotPolicy": "inherit-minimal", "configFingerprint": params.config_fingerprint, "versionFingerprint": params.version_fingerprint, "packageFingerprint": params.runtime_package_fingerprint, - "recoveryPolicy": WORKFLOW_BRANCH_RECOVERY_POLICY, + "recoveryPolicy": recovery_policy, "maxRuntimeSeconds": workflow_state_data(&run.limits_json).get("max_step_runtime_seconds"), + "routeReceipt": route_receipt, + "contextCeilingTokens": route_receipt.effective.context_ceiling_tokens, + "creditAccounting": route_receipt.terminal_credit_accounting(), }) } +fn workflow_branch_route_receipt( + model_route_json: &Value, + workspace_json: Option<&Value>, + runtime: &WorkflowRouteRuntime, +) -> Result { + let requested = serde_json::from_value::(model_route_json.clone()) + .map_err(|err| { + WorkflowRouteEnforcementError::new( + "workflow_route_schema_invalid", + format!("workflow route is invalid: {err}"), + ) + })?; + let worktree_mode = match workflow_workspace_mode(workspace_json).map_err(|err| { + WorkflowRouteEnforcementError::new("workflow_route_worktree_mode_invalid", err.to_string()) + })? { + WorkflowWorkspaceMode::IsolatedWorktree => "isolated_worktree", + WorkflowWorkspaceMode::SharedRepository => "shared_repository", + }; + admit_workflow_model_route_for_runtime(&requested, runtime, worktree_mode) +} + fn validate_workflow_permission_profile_json( permission_profile_json: &Value, ) -> anyhow::Result<()> { @@ -3212,7 +3287,7 @@ cleanup: .collect::(); let mut steps = (0..step_count) .map(|index| { - let route = if index == 0 { + let route = if index == 0 && title_suffix == "missing-openrouter-env" { r#" model: model_gateway: "openrouter" provider: "openrouter" @@ -3220,6 +3295,15 @@ cleanup: reasoning: "xhigh" service_tier: "priority" permission_profile: "read-only" +"# + .to_string() + } else if index == 0 && title_suffix == "typed-permission-profile" { + r#" model: + model_gateway: "hasna" + provider: "openai" + model: "gpt-5.4" + reasoning: "high" + permission_profile: "read-only" "# .to_string() } else { @@ -3410,6 +3494,31 @@ agents: .expect("read-only permission profile should serialize") } + fn workflow_route_runtime() -> WorkflowRouteRuntime { + WorkflowRouteRuntime { + model_gateway: Some("hasna".to_string()), + provider: Some("openai".to_string()), + model: Some("gpt-5.4".to_string()), + reasoning: Some("high".to_string()), + permission_profile: Some("read-only".to_string()), + context_window_tokens: Some(128_000), + ..Default::default() + } + } + + fn openrouter_workflow_route_runtime() -> WorkflowRouteRuntime { + WorkflowRouteRuntime { + model_gateway: Some("openrouter".to_string()), + provider: Some("openrouter".to_string()), + model: Some("openai/gpt-oss-120b".to_string()), + reasoning: Some("xhigh".to_string()), + service_tier: Some("priority".to_string()), + permission_profile: Some("read-only".to_string()), + context_window_tokens: Some(128_000), + ..Default::default() + } + } + async fn mark_projected_node_complete( runtime: &StateRuntime, projection: &WorkflowGoalPlanProjectionOutcome, @@ -3898,6 +4007,7 @@ WHERE plan_id = ? AND key = ? version_fingerprint: Some("version-workflow".to_string()), runtime_package_fingerprint: Some("package-workflow".to_string()), permission_profile_json: read_only_permission_profile_json(), + route_runtime: workflow_route_runtime(), parent_agent_run_id: None, max_active_background_agent_runs: Some(10), }, @@ -3936,14 +4046,14 @@ WHERE plan_id = ? AND key = ? assert_eq!(1, ready_steps); let first_branch = &admitted.admitted[0]; assert_eq!( - Some("openrouter"), + Some("hasna"), first_branch .model_route_json .get("model_gateway") .and_then(Value::as_str) ); assert_eq!( - Some("xhigh"), + Some("high"), first_branch .model_route_json .get("reasoning") @@ -3971,14 +4081,14 @@ WHERE plan_id = ? AND key = ? .expect("execution snapshot should load") .expect("execution snapshot should exist"); assert_eq!( - Some("openrouter"), + Some("hasna"), execution_snapshot .payload_json .get("modelGateway") .and_then(Value::as_str) ); assert_eq!( - Some("xhigh"), + Some("high"), execution_snapshot .payload_json .get("reasoning") @@ -4112,6 +4222,7 @@ WHERE worktree_id = ? version_fingerprint: None, runtime_package_fingerprint: None, permission_profile_json: permission_profile_json.clone(), + route_runtime: workflow_route_runtime(), parent_agent_run_id: None, max_active_background_agent_runs: Some(10), }, @@ -4138,6 +4249,14 @@ WHERE worktree_id = ? .expect("initial execution snapshot should include permissionProfile"); assert_eq!(&permission_profile_json, persisted_permission_profile); assert!(persisted_permission_profile.is_object()); + assert_eq!( + execution_snapshot + .payload_json + .pointer("/activePermissionProfile/id") + .and_then(Value::as_str), + branch.route_receipt.effective.permission_profile.as_deref(), + "the persisted compiled profile must retain its immutable active identity" + ); assert_ne!( branch.model_route_json.get("permission_profile"), Some(persisted_permission_profile), @@ -4192,6 +4311,7 @@ WHERE worktree_id = ? version_fingerprint: None, runtime_package_fingerprint: None, permission_profile_json, + route_runtime: workflow_route_runtime(), parent_agent_run_id: None, max_active_background_agent_runs: Some(10), }, @@ -4304,6 +4424,7 @@ WHERE worktree_id = ? version_fingerprint: None, runtime_package_fingerprint: None, permission_profile_json: read_only_permission_profile_json(), + route_runtime: workflow_route_runtime(), parent_agent_run_id: None, max_active_background_agent_runs: Some(10), }, @@ -4393,10 +4514,10 @@ WHERE worktree_id = ? "wf_branch_openrouter_missing_env", parallel_branch_workflow_yaml( "wf_branch_openrouter_missing_env", - /*step_count*/ 2, - /*max_parallel_steps*/ 2, + /*step_count*/ 1, + /*max_parallel_steps*/ 1, /*max_agents*/ 2, - /*max_worktrees*/ 2, + /*max_worktrees*/ 1, "missing-openrouter-env", ), ) @@ -4440,6 +4561,7 @@ WHERE worktree_id = ? version_fingerprint: None, runtime_package_fingerprint: None, permission_profile_json: read_only_permission_profile_json(), + route_runtime: openrouter_workflow_route_runtime(), parent_agent_run_id: None, max_active_background_agent_runs: Some(10), }, @@ -4480,8 +4602,8 @@ WHERE worktree_id = ? None, ), ( - "branch_1".to_string(), - crate::WorkflowRunStepStatus::Ready, + "initial_adversarial_review".to_string(), + crate::WorkflowRunStepStatus::Pending, None, None, ), @@ -4577,6 +4699,7 @@ WHERE worktree_id = ? version_fingerprint: None, runtime_package_fingerprint: None, permission_profile_json: read_only_permission_profile_json(), + route_runtime: openrouter_workflow_route_runtime(), parent_agent_run_id: None, max_active_background_agent_runs: Some(10), }, @@ -4638,6 +4761,7 @@ WHERE worktree_id = ? version_fingerprint: None, runtime_package_fingerprint: None, permission_profile_json: read_only_permission_profile_json(), + route_runtime: workflow_route_runtime(), parent_agent_run_id: None, max_active_background_agent_runs: Some(10), }, @@ -4685,6 +4809,7 @@ WHERE worktree_id = ? version_fingerprint: None, runtime_package_fingerprint: None, permission_profile_json: read_only_permission_profile_json(), + route_runtime: workflow_route_runtime(), parent_agent_run_id: None, max_active_background_agent_runs: Some(10), }, @@ -4723,6 +4848,7 @@ WHERE worktree_id = ? version_fingerprint: None, runtime_package_fingerprint: None, permission_profile_json: read_only_permission_profile_json(), + route_runtime: workflow_route_runtime(), parent_agent_run_id: None, max_active_background_agent_runs: Some(10), }, @@ -4823,6 +4949,7 @@ WHERE worktree_id = ? version_fingerprint: None, runtime_package_fingerprint: None, permission_profile_json: read_only_permission_profile_json(), + route_runtime: workflow_route_runtime(), parent_agent_run_id: None, max_active_background_agent_runs: Some(10), }, @@ -4842,6 +4969,7 @@ WHERE worktree_id = ? version_fingerprint: None, runtime_package_fingerprint: None, permission_profile_json: read_only_permission_profile_json(), + route_runtime: workflow_route_runtime(), parent_agent_run_id: None, max_active_background_agent_runs: Some(10), }, @@ -4877,6 +5005,7 @@ WHERE worktree_id = ? version_fingerprint: None, runtime_package_fingerprint: None, permission_profile_json: read_only_permission_profile_json(), + route_runtime: workflow_route_runtime(), parent_agent_run_id: None, max_active_background_agent_runs: Some(10), }, @@ -4947,6 +5076,7 @@ WHERE worktree_id = ? version_fingerprint: None, runtime_package_fingerprint: None, permission_profile_json: read_only_permission_profile_json(), + route_runtime: workflow_route_runtime(), parent_agent_run_id: None, max_active_background_agent_runs: Some(10), }, @@ -5102,6 +5232,7 @@ WHERE run_id = ? version_fingerprint: None, runtime_package_fingerprint: None, permission_profile_json: read_only_permission_profile_json(), + route_runtime: workflow_route_runtime(), parent_agent_run_id: None, max_active_background_agent_runs: Some(10), }, @@ -5607,6 +5738,7 @@ cleanup: version_fingerprint: None, runtime_package_fingerprint: None, permission_profile_json: read_only_permission_profile_json(), + route_runtime: workflow_route_runtime(), parent_agent_run_id: None, max_active_background_agent_runs: Some(10), }) diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index f79df74f4..0d61a69f1 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -178,6 +178,7 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R model_provider_id, model_gateway_id: HASNA_GATEWAY_ID.to_string(), model_provider, + workflow_route_receipt: None, personality: None, permissions: Permissions::from_approval_and_profile( Constrained::allow_any(AskForApproval::Never), diff --git a/codex-rs/workflows/src/lib.rs b/codex-rs/workflows/src/lib.rs index 829599874..e8249bcb0 100644 --- a/codex-rs/workflows/src/lib.rs +++ b/codex-rs/workflows/src/lib.rs @@ -8,6 +8,7 @@ mod ancient_names; mod branch_prompt; mod error; mod parse; +mod routing_enforcement; mod spec; mod validation; @@ -16,6 +17,14 @@ pub use branch_prompt::render_workflow_branch_prompt; pub use error::WorkflowSpecError; pub use error::WorkflowSpecResult; pub use parse::parse_workflow_yaml; +pub use routing_enforcement::WorkflowEffectiveModelRoute; +pub use routing_enforcement::WorkflowProviderCreditControl; +pub use routing_enforcement::WorkflowProviderCreditTerminalAccounting; +pub use routing_enforcement::WorkflowRouteEnforcementError; +pub use routing_enforcement::WorkflowRouteReceipt; +pub use routing_enforcement::WorkflowRouteRuntime; +pub use routing_enforcement::admit_workflow_model_route; +pub use routing_enforcement::admit_workflow_model_route_for_runtime; pub use spec::WorkflowAgent; pub use spec::WorkflowApprovals; pub use spec::WorkflowArtifacts; diff --git a/codex-rs/workflows/src/routing_enforcement.rs b/codex-rs/workflows/src/routing_enforcement.rs new file mode 100644 index 000000000..d00f18fc7 --- /dev/null +++ b/codex-rs/workflows/src/routing_enforcement.rs @@ -0,0 +1,1153 @@ +use serde::Deserialize; +use serde::Serialize; + +use crate::WorkflowModelRoute; +use crate::WorkflowModelRoutingConstraints; +use crate::WorkflowModelRoutingDecisionStatus; + +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case", tag = "status")] +pub enum WorkflowProviderCreditControl { + NotRequested, + #[default] + Unavailable, + Reserved { + reservation_id: String, + ceiling_usd: String, + spent_usd: String, + remaining_usd: String, + exhausted: bool, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkflowEffectiveModelRoute { + pub model_gateway: String, + pub provider: String, + pub model: String, + pub reasoning: String, + pub service_tier: Option, + pub auth_profile: Option, + pub approval_policy: Option, + pub permission_profile: Option, + pub worktree_mode: String, + pub context_ceiling_tokens: Option, + pub fallback_used: bool, + pub credit_control: WorkflowProviderCreditControl, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkflowRouteRuntime { + pub model_gateway: Option, + pub provider: Option, + pub model: Option, + pub reasoning: Option, + pub service_tier: Option, + pub auth_profile: Option, + pub approval_policy: Option, + pub permission_profile: Option, + pub context_window_tokens: Option, + pub credit_control: WorkflowProviderCreditControl, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case", tag = "status")] +pub enum WorkflowProviderCreditTerminalAccounting { + NotRequested, + ProviderReadback { + reservation_id: String, + ceiling_usd: String, + spent_usd: String, + remaining_usd: String, + exhausted: bool, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkflowRouteReceipt { + pub requested: WorkflowModelRoute, + pub effective: WorkflowEffectiveModelRoute, +} + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[error("{code}: {message}")] +pub struct WorkflowRouteEnforcementError { + code: &'static str, + message: String, +} + +impl WorkflowRouteEnforcementError { + pub fn new(code: &'static str, message: impl Into) -> Self { + Self { + code, + message: message.into(), + } + } + + pub fn code(&self) -> &'static str { + self.code + } +} + +pub fn admit_workflow_model_route( + requested: &WorkflowModelRoute, + effective: &WorkflowEffectiveModelRoute, +) -> Result { + enforce_exact( + "workflow_route_gateway_mismatch", + "model gateway", + requested.model_gateway.as_str(), + effective.model_gateway.as_str(), + )?; + enforce_exact( + "workflow_route_provider_mismatch", + "provider", + requested.provider.as_str(), + effective.provider.as_str(), + )?; + enforce_exact( + "workflow_route_model_mismatch", + "model", + requested.model.as_str(), + effective.model.as_str(), + )?; + enforce_exact( + "workflow_route_reasoning_mismatch", + "reasoning", + requested.reasoning.as_str(), + effective.reasoning.as_str(), + )?; + enforce_optional_exact( + "workflow_route_service_tier_mismatch", + "service tier", + requested.service_tier.as_deref(), + effective.service_tier.as_deref(), + )?; + enforce_optional_exact( + "workflow_route_approval_profile_mismatch", + "approval profile", + requested.approval_policy.as_deref(), + effective.approval_policy.as_deref(), + )?; + enforce_optional_exact( + "workflow_route_permission_profile_mismatch", + "permission profile", + requested.permission_profile.as_deref(), + effective.permission_profile.as_deref(), + )?; + + if let Some(routing) = requested.routing.as_ref() { + let Some(decision) = routing.decision.as_ref() else { + return Err(route_error( + "workflow_route_decision_missing", + "routing contract has no immutable decision", + )); + }; + if decision.status == WorkflowModelRoutingDecisionStatus::Error { + return Err(route_error( + "workflow_route_decision_error", + "routing decision is an error and cannot be executed", + )); + } + enforce_optional_exact( + "workflow_route_gateway_mismatch", + "routing decision model gateway", + decision.model_gateway.as_deref(), + Some(effective.model_gateway.as_str()), + )?; + enforce_optional_exact( + "workflow_route_provider_mismatch", + "routing decision provider", + decision.provider.as_deref(), + Some(effective.provider.as_str()), + )?; + enforce_optional_exact( + "workflow_route_model_mismatch", + "routing decision model", + decision.model.as_deref(), + Some(effective.model.as_str()), + )?; + enforce_optional_exact( + "workflow_route_reasoning_mismatch", + "routing decision reasoning", + decision.reasoning.as_deref(), + Some(effective.reasoning.as_str()), + )?; + enforce_optional_exact( + "workflow_route_service_tier_mismatch", + "routing decision service tier", + decision.service_tier.as_deref(), + effective.service_tier.as_deref(), + )?; + enforce_optional_exact( + "workflow_route_auth_profile_mismatch", + "routing decision auth profile", + decision.auth_profile.as_deref(), + effective.auth_profile.as_deref(), + )?; + let context = &routing.request.context; + enforce_optional_exact( + "workflow_route_auth_profile_mismatch", + "routing context auth profile", + context.auth_profile.as_deref(), + effective.auth_profile.as_deref(), + )?; + enforce_optional_exact( + "workflow_route_approval_profile_mismatch", + "routing context approval profile", + context.approval_policy.as_deref(), + effective.approval_policy.as_deref(), + )?; + enforce_optional_exact( + "workflow_route_permission_profile_mismatch", + "routing context permission profile", + context.permission_profile.as_deref(), + effective.permission_profile.as_deref(), + )?; + enforce_optional_exact( + "workflow_route_worktree_mode_mismatch", + "routing context worktree mode", + context.worktree_mode.as_deref(), + Some(effective.worktree_mode.as_str()), + )?; + + let constraints = &routing.request.constraints; + enforce_constraints(constraints, decision.status, effective)?; + let decision_fallback_used = decision + .fallback + .as_ref() + .is_some_and(|fallback| fallback.used); + if (decision.status == WorkflowModelRoutingDecisionStatus::Fallback) + != decision_fallback_used + { + return Err(route_error( + "workflow_route_fallback_mismatch", + "routing fallback status and usage marker disagree", + )); + } + if decision_fallback_used != effective.fallback_used { + return Err(route_error( + "workflow_route_fallback_mismatch", + "effective fallback decision does not match the persisted routing decision", + )); + } + if constraints.fallback_required + && (decision.status != WorkflowModelRoutingDecisionStatus::Fallback + || !effective.fallback_used) + { + return Err(route_error( + "workflow_route_required_fallback_missing", + "routing requires an explicit fallback decision", + )); + } + enforce_credit_control(constraints.budget_usd.as_deref(), &effective.credit_control)?; + } else if !matches!( + effective.credit_control, + WorkflowProviderCreditControl::NotRequested + ) { + return Err(route_error( + "workflow_route_credit_control_unexpected", + "credit control was supplied without a routing budget contract", + )); + } + + Ok(WorkflowRouteReceipt { + requested: requested.clone(), + effective: effective.clone(), + }) +} + +pub fn admit_workflow_model_route_for_runtime( + requested: &WorkflowModelRoute, + runtime: &WorkflowRouteRuntime, + worktree_mode: &str, +) -> Result { + let constraints = requested + .routing + .as_ref() + .map(|routing| &routing.request.constraints); + let context_ceiling_tokens = constraints + .and_then(|constraints| constraints.max_context_tokens) + .map(|ceiling| { + let available = runtime.context_window_tokens.ok_or_else(|| { + route_error( + "workflow_route_context_ceiling_unavailable", + "runtime cannot prove the requested context ceiling before launch", + ) + })?; + if available < ceiling { + return Err(route_error( + "workflow_route_context_ceiling_unavailable", + "runtime context window is smaller than the requested immutable ceiling", + )); + } + Ok(ceiling) + }) + .transpose()?; + let fallback_used = requested + .routing + .as_ref() + .and_then(|routing| routing.decision.as_ref()) + .and_then(|decision| decision.fallback.as_ref()) + .is_some_and(|fallback| fallback.used); + let finite_budget_requested = constraints + .and_then(|constraints| constraints.budget_usd.as_ref()) + .is_some(); + let effective = WorkflowEffectiveModelRoute { + model_gateway: required_runtime_value( + "workflow_route_gateway_unavailable", + "model gateway", + runtime.model_gateway.as_deref(), + )?, + provider: required_runtime_value( + "workflow_route_provider_unavailable", + "provider", + runtime.provider.as_deref(), + )?, + model: required_runtime_value( + "workflow_route_model_unavailable", + "model", + runtime.model.as_deref(), + )?, + reasoning: required_runtime_value( + "workflow_route_reasoning_unavailable", + "reasoning", + runtime.reasoning.as_deref(), + )?, + service_tier: runtime.service_tier.clone(), + auth_profile: runtime.auth_profile.clone(), + approval_policy: runtime.approval_policy.clone(), + permission_profile: runtime.permission_profile.clone(), + worktree_mode: worktree_mode.to_string(), + context_ceiling_tokens, + fallback_used, + credit_control: if finite_budget_requested { + runtime.credit_control.clone() + } else { + WorkflowProviderCreditControl::NotRequested + }, + }; + admit_workflow_model_route(requested, &effective) +} + +impl WorkflowRouteReceipt { + pub fn enforce_provider_attempt( + &self, + effective: &WorkflowEffectiveModelRoute, + ) -> Result<(), WorkflowRouteEnforcementError> { + let repeated = admit_workflow_model_route(&self.requested, effective)?; + if repeated.effective != self.effective { + return Err(route_error( + "workflow_route_receipt_mismatch", + "provider attempt route differs from its immutable admission receipt", + )); + } + Ok(()) + } + + pub fn enforce_descendant( + &self, + effective: &WorkflowEffectiveModelRoute, + ) -> Result<(), WorkflowRouteEnforcementError> { + self.enforce_provider_attempt(effective) + } + + pub fn terminal_credit_accounting(&self) -> WorkflowProviderCreditTerminalAccounting { + match &self.effective.credit_control { + WorkflowProviderCreditControl::NotRequested => { + WorkflowProviderCreditTerminalAccounting::NotRequested + } + WorkflowProviderCreditControl::Reserved { + reservation_id, + ceiling_usd, + spent_usd, + remaining_usd, + exhausted, + } => WorkflowProviderCreditTerminalAccounting::ProviderReadback { + reservation_id: reservation_id.clone(), + ceiling_usd: ceiling_usd.clone(), + spent_usd: spent_usd.clone(), + remaining_usd: remaining_usd.clone(), + exhausted: *exhausted, + }, + WorkflowProviderCreditControl::Unavailable => { + unreachable!("unavailable credit control cannot be admitted") + } + } + } +} + +fn enforce_constraints( + constraints: &WorkflowModelRoutingConstraints, + decision_status: WorkflowModelRoutingDecisionStatus, + effective: &WorkflowEffectiveModelRoute, +) -> Result<(), WorkflowRouteEnforcementError> { + enforce_list( + "workflow_route_gateway_mismatch", + "model gateway", + effective.model_gateway.as_str(), + &constraints.allowed_model_gateways, + )?; + enforce_list( + "workflow_route_provider_mismatch", + "provider", + effective.provider.as_str(), + &constraints.allowed_providers, + )?; + enforce_list( + "workflow_route_model_mismatch", + "model", + effective.model.as_str(), + &constraints.allowed_models, + )?; + enforce_list( + "workflow_route_reasoning_mismatch", + "reasoning", + effective.reasoning.as_str(), + &constraints.allowed_reasoning, + )?; + enforce_optional_list( + "workflow_route_service_tier_mismatch", + "service tier", + effective.service_tier.as_deref(), + &constraints.allowed_service_tiers, + )?; + enforce_optional_list( + "workflow_route_auth_profile_mismatch", + "auth profile", + effective.auth_profile.as_deref(), + &constraints.allowed_auth_profiles, + )?; + enforce_optional_list( + "workflow_route_approval_profile_mismatch", + "approval profile", + effective.approval_policy.as_deref(), + &constraints.allowed_approval_policies, + )?; + enforce_optional_list( + "workflow_route_permission_profile_mismatch", + "permission profile", + effective.permission_profile.as_deref(), + &constraints.allowed_permission_profiles, + )?; + enforce_list( + "workflow_route_worktree_mode_mismatch", + "worktree mode", + effective.worktree_mode.as_str(), + &constraints.allowed_worktree_modes, + )?; + + if decision_status == WorkflowModelRoutingDecisionStatus::Selected { + enforce_list( + "workflow_route_gateway_not_preferred", + "preferred model gateway", + effective.model_gateway.as_str(), + &constraints.preferred_model_gateways, + )?; + enforce_list( + "workflow_route_provider_not_preferred", + "preferred provider", + effective.provider.as_str(), + &constraints.preferred_providers, + )?; + enforce_list( + "workflow_route_model_not_preferred", + "preferred model", + effective.model.as_str(), + &constraints.preferred_models, + )?; + enforce_list( + "workflow_route_reasoning_not_preferred", + "preferred reasoning", + effective.reasoning.as_str(), + &constraints.preferred_reasoning, + )?; + enforce_optional_list( + "workflow_route_service_tier_not_preferred", + "preferred service tier", + effective.service_tier.as_deref(), + &constraints.preferred_service_tiers, + )?; + } + + if let Some(max_context_tokens) = constraints.max_context_tokens { + let Some(context_ceiling_tokens) = effective.context_ceiling_tokens else { + return Err(route_error( + "workflow_route_context_ceiling_unavailable", + "runtime cannot establish the requested context ceiling", + )); + }; + if context_ceiling_tokens > max_context_tokens { + return Err(route_error( + "workflow_route_context_ceiling_exceeded", + "effective context ceiling exceeds the routing constraint", + )); + } + } + Ok(()) +} + +fn enforce_credit_control( + budget_usd: Option<&str>, + control: &WorkflowProviderCreditControl, +) -> Result<(), WorkflowRouteEnforcementError> { + match (budget_usd, control) { + (None, WorkflowProviderCreditControl::NotRequested) => Ok(()), + (Some(_), WorkflowProviderCreditControl::Unavailable) => Err(route_error( + "workflow_route_credit_ceiling_unavailable", + "provider cannot establish a pre-launch credit reservation and accounting readback", + )), + ( + Some(budget_usd), + WorkflowProviderCreditControl::Reserved { + reservation_id, + ceiling_usd, + spent_usd, + remaining_usd, + exhausted, + }, + ) => { + if reservation_id.trim().is_empty() { + return Err(route_error( + "workflow_route_credit_accounting_invalid", + "provider reservation id is missing", + )); + } + if ceiling_usd != budget_usd { + return Err(route_error( + "workflow_route_credit_ceiling_mismatch", + "provider reservation ceiling differs from the requested budget", + )); + } + if !credit_accounting_balances(ceiling_usd, spent_usd, remaining_usd) { + return Err(route_error( + "workflow_route_credit_accounting_invalid", + "provider credit readback does not balance to the reserved ceiling", + )); + } + if *exhausted || decimal_is_zero(remaining_usd) { + return Err(route_error( + "workflow_route_credit_ceiling_exhausted", + "provider reservation has no credit remaining", + )); + } + Err(route_error( + "workflow_route_credit_ceiling_unavailable", + "serialized provider accounting cannot establish the required pre-launch credit controller", + )) + } + (Some(_), WorkflowProviderCreditControl::NotRequested) => Err(route_error( + "workflow_route_credit_ceiling_unavailable", + "finite routing budget has no provider credit reservation", + )), + (None, _) => Err(route_error( + "workflow_route_credit_control_unexpected", + "provider credit control was supplied without a finite routing budget", + )), + } +} + +fn decimal_is_zero(value: &str) -> bool { + let value = value.trim(); + !value.is_empty() + && !value.starts_with('-') + && value + .chars() + .all(|character| character.is_ascii_digit() || character == '.') + && value.chars().any(|character| character.is_ascii_digit()) + && value + .chars() + .filter(char::is_ascii_digit) + .all(|character| character == '0') +} + +fn credit_accounting_balances(ceiling: &str, spent: &str, remaining: &str) -> bool { + let Some(scale) = [ceiling, spent, remaining] + .into_iter() + .map(decimal_scale) + .collect::>>() + .and_then(|scales| scales.into_iter().max()) + else { + return false; + }; + let Some(ceiling) = decimal_at_scale(ceiling, scale) else { + return false; + }; + let Some(spent) = decimal_at_scale(spent, scale) else { + return false; + }; + let Some(remaining) = decimal_at_scale(remaining, scale) else { + return false; + }; + spent + .checked_add(remaining) + .is_some_and(|total| total == ceiling) +} + +fn decimal_scale(value: &str) -> Option { + let value = value.trim(); + let mut parts = value.split('.'); + let whole = parts.next()?; + let fraction = parts.next(); + if parts.next().is_some() + || whole.is_empty() + || !whole.chars().all(|character| character.is_ascii_digit()) + || fraction.is_some_and(|fraction| { + fraction.is_empty() || !fraction.chars().all(|character| character.is_ascii_digit()) + }) + { + return None; + } + Some(fraction.map_or(0, str::len)) +} + +fn decimal_at_scale(value: &str, scale: usize) -> Option { + let value = value.trim(); + let (whole, fraction) = value.split_once('.').unwrap_or((value, "")); + if fraction.len() > scale { + return None; + } + let mut digits = String::with_capacity(whole.len().saturating_add(scale)); + digits.push_str(whole); + digits.push_str(fraction); + digits.extend(std::iter::repeat_n( + '0', + scale.saturating_sub(fraction.len()), + )); + digits.parse::().ok() +} + +fn enforce_exact( + code: &'static str, + field: &str, + expected: &str, + actual: &str, +) -> Result<(), WorkflowRouteEnforcementError> { + if expected == actual { + Ok(()) + } else { + Err(route_error( + code, + format!("effective {field} does not match the exact requested value"), + )) + } +} + +fn enforce_optional_exact( + code: &'static str, + field: &str, + expected: Option<&str>, + actual: Option<&str>, +) -> Result<(), WorkflowRouteEnforcementError> { + if expected.is_none() || expected == actual { + Ok(()) + } else { + Err(route_error( + code, + format!("effective {field} does not match the exact requested value"), + )) + } +} + +fn enforce_list( + code: &'static str, + field: &str, + actual: &str, + allowed: &[String], +) -> Result<(), WorkflowRouteEnforcementError> { + if allowed.is_empty() || allowed.iter().any(|value| value == actual) { + Ok(()) + } else { + Err(route_error( + code, + format!("effective {field} is not allowed by the routing contract"), + )) + } +} + +fn enforce_optional_list( + code: &'static str, + field: &str, + actual: Option<&str>, + allowed: &[String], +) -> Result<(), WorkflowRouteEnforcementError> { + if allowed.is_empty() + || actual.is_some_and(|actual| allowed.iter().any(|value| value == actual)) + { + Ok(()) + } else { + Err(route_error( + code, + format!("effective {field} is missing or not allowed by the routing contract"), + )) + } +} + +fn route_error(code: &'static str, message: impl Into) -> WorkflowRouteEnforcementError { + WorkflowRouteEnforcementError::new(code, message) +} + +fn required_runtime_value( + code: &'static str, + field: &str, + value: Option<&str>, +) -> Result { + value + .filter(|value| !value.trim().is_empty()) + .map(str::to_string) + .ok_or_else(|| route_error(code, format!("runtime {field} is unavailable"))) +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use std::sync::atomic::AtomicUsize; + use std::sync::atomic::Ordering; + + use crate::WorkflowEffectiveModelRoute; + use crate::WorkflowModelRoute; + use crate::WorkflowModelRouter; + use crate::WorkflowModelRoutingCapability; + use crate::WorkflowModelRoutingConstraints; + use crate::WorkflowModelRoutingContext; + use crate::WorkflowModelRoutingContract; + use crate::WorkflowModelRoutingDecision; + use crate::WorkflowModelRoutingDecisionStatus; + use crate::WorkflowModelRoutingFallback; + use crate::WorkflowModelRoutingRequest; + use crate::WorkflowProviderCreditControl; + use crate::admit_workflow_model_route; + use crate::admit_workflow_model_route_for_runtime; + + fn exact_route() -> WorkflowModelRoute { + WorkflowModelRoute { + model_gateway: "openrouter".to_string(), + provider: "openrouter".to_string(), + model: "openai/gpt-5.4".to_string(), + reasoning: "high".to_string(), + service_tier: Some("priority".to_string()), + approval_policy: Some("never".to_string()), + permission_profile: Some(":workspace".to_string()), + routing: Some(WorkflowModelRoutingContract { + contract_version: "openrouter.route/v1".to_string(), + router: WorkflowModelRouter::OpenRouter, + request: WorkflowModelRoutingRequest { + requested_capability: WorkflowModelRoutingCapability::AgentWorker, + context: WorkflowModelRoutingContext { + auth_profile: Some("account007".to_string()), + approval_policy: Some("never".to_string()), + permission_profile: Some(":workspace".to_string()), + worktree_mode: Some("isolated_worktree".to_string()), + ..Default::default() + }, + constraints: WorkflowModelRoutingConstraints { + allowed_model_gateways: vec!["openrouter".to_string()], + preferred_model_gateways: vec!["openrouter".to_string()], + allowed_providers: vec!["openrouter".to_string()], + preferred_providers: vec!["openrouter".to_string()], + allowed_models: vec!["openai/gpt-5.4".to_string()], + preferred_models: vec!["openai/gpt-5.4".to_string()], + allowed_reasoning: vec!["high".to_string()], + preferred_reasoning: vec!["high".to_string()], + allowed_service_tiers: vec!["priority".to_string()], + preferred_service_tiers: vec!["priority".to_string()], + allowed_auth_profiles: vec!["account007".to_string()], + allowed_approval_policies: vec!["never".to_string()], + allowed_permission_profiles: vec![":workspace".to_string()], + allowed_worktree_modes: vec!["isolated_worktree".to_string()], + max_context_tokens: Some(128_000), + budget_usd: None, + fallback_required: false, + }, + }, + decision: Some(WorkflowModelRoutingDecision { + status: WorkflowModelRoutingDecisionStatus::Selected, + model_gateway: Some("openrouter".to_string()), + provider: Some("openrouter".to_string()), + model: Some("openai/gpt-5.4".to_string()), + reasoning: Some("high".to_string()), + service_tier: Some("priority".to_string()), + auth_profile: Some("account007".to_string()), + explanation: Some("exact route selected".to_string()), + fallback: Some(WorkflowModelRoutingFallback { + used: false, + reason: None, + }), + warnings: Vec::new(), + errors: Vec::new(), + }), + }), + } + } + + fn effective_route() -> WorkflowEffectiveModelRoute { + WorkflowEffectiveModelRoute { + model_gateway: "openrouter".to_string(), + provider: "openrouter".to_string(), + model: "openai/gpt-5.4".to_string(), + reasoning: "high".to_string(), + service_tier: Some("priority".to_string()), + auth_profile: Some("account007".to_string()), + approval_policy: Some("never".to_string()), + permission_profile: Some(":workspace".to_string()), + worktree_mode: "isolated_worktree".to_string(), + context_ceiling_tokens: Some(128_000), + fallback_used: false, + credit_control: WorkflowProviderCreditControl::NotRequested, + } + } + + fn runtime() -> crate::WorkflowRouteRuntime { + crate::WorkflowRouteRuntime { + model_gateway: Some("openrouter".to_string()), + provider: Some("openrouter".to_string()), + model: Some("openai/gpt-5.4".to_string()), + reasoning: Some("high".to_string()), + service_tier: Some("priority".to_string()), + auth_profile: Some("account007".to_string()), + approval_policy: Some("never".to_string()), + permission_profile: Some(":workspace".to_string()), + context_window_tokens: Some(256_000), + credit_control: WorkflowProviderCreditControl::NotRequested, + } + } + + fn invoke_provider_after_route_admission( + requested: &WorkflowModelRoute, + effective: &WorkflowEffectiveModelRoute, + provider_calls: &AtomicUsize, + ) -> Result<(), crate::WorkflowRouteEnforcementError> { + let receipt = admit_workflow_model_route(requested, effective)?; + receipt.enforce_provider_attempt(effective)?; + provider_calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + + fn invoke_provider_after_runtime_admission( + requested: &WorkflowModelRoute, + runtime: &crate::WorkflowRouteRuntime, + worktree_mode: &str, + provider_calls: &AtomicUsize, + ) -> Result<(), crate::WorkflowRouteEnforcementError> { + let receipt = admit_workflow_model_route_for_runtime(requested, runtime, worktree_mode)?; + receipt.enforce_provider_attempt(&receipt.effective)?; + provider_calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + + #[test] + fn fully_supported_exact_route_persists_immutable_requested_and_effective_receipt() { + let route = exact_route(); + let effective = effective_route(); + + let receipt = admit_workflow_model_route(&route, &effective).expect("route is supported"); + + assert_eq!(receipt.requested, route); + assert_eq!(receipt.effective, effective); + assert_eq!(receipt.enforce_provider_attempt(&effective), Ok(())); + assert_eq!(receipt.enforce_descendant(&effective), Ok(())); + assert_eq!( + receipt.terminal_credit_accounting(), + crate::WorkflowProviderCreditTerminalAccounting::NotRequested + ); + let provider_calls = AtomicUsize::new(0); + invoke_provider_after_route_admission(&route, &effective, &provider_calls) + .expect("fully supported route can invoke the provider"); + assert_eq!(provider_calls.load(Ordering::SeqCst), 1); + let runtime_provider_calls = AtomicUsize::new(0); + invoke_provider_after_runtime_admission( + &route, + &runtime(), + "isolated_worktree", + &runtime_provider_calls, + ) + .expect("fully supported runtime route can invoke the provider"); + assert_eq!(runtime_provider_calls.load(Ordering::SeqCst), 1); + } + + #[test] + fn route_receipt_is_identical_across_restart_and_descendant_revalidation() { + let route = exact_route(); + let effective = effective_route(); + let first = admit_workflow_model_route(&route, &effective).expect("first admission"); + let restarted = admit_workflow_model_route(&route, &effective).expect("restart admission"); + + assert_eq!(restarted, first); + assert_eq!(restarted.enforce_descendant(&effective), Ok(())); + } + + #[test] + fn every_disallowed_or_mismatched_route_dimension_fails_closed() { + type EffectiveRouteMutation = fn(&mut WorkflowEffectiveModelRoute); + let cases: [(&str, EffectiveRouteMutation); 10] = [ + ("workflow_route_gateway_mismatch", |route| { + route.model_gateway = "direct".to_string() + }), + ("workflow_route_provider_mismatch", |route| { + route.provider = "openai".to_string() + }), + ("workflow_route_model_mismatch", |route| { + route.model = "openai/gpt-4.1".to_string() + }), + ("workflow_route_reasoning_mismatch", |route| { + route.reasoning = "medium".to_string() + }), + ("workflow_route_service_tier_mismatch", |route| { + route.service_tier = Some("default".to_string()) + }), + ("workflow_route_auth_profile_mismatch", |route| { + route.auth_profile = Some("account008".to_string()) + }), + ("workflow_route_approval_profile_mismatch", |route| { + route.approval_policy = Some("on-request".to_string()) + }), + ("workflow_route_permission_profile_mismatch", |route| { + route.permission_profile = Some(":read-only".to_string()) + }), + ("workflow_route_worktree_mode_mismatch", |route| { + route.worktree_mode = "shared_repository".to_string() + }), + ("workflow_route_context_ceiling_exceeded", |route| { + route.context_ceiling_tokens = Some(128_001) + }), + ]; + + for (expected_code, mutate) in cases { + let mut effective = effective_route(); + mutate(&mut effective); + let provider_calls = AtomicUsize::new(0); + let error = + invoke_provider_after_route_admission(&exact_route(), &effective, &provider_calls) + .expect_err("mismatched route must fail closed"); + assert_eq!(error.code(), expected_code); + assert_eq!(provider_calls.load(Ordering::SeqCst), 0); + } + } + + #[test] + fn unavailable_required_runtime_dimensions_fail_before_provider_attempt() { + type RuntimeRouteMutation = fn(&mut crate::WorkflowRouteRuntime); + let cases: [(&str, RuntimeRouteMutation); 9] = [ + ("workflow_route_gateway_unavailable", |runtime| { + runtime.model_gateway = None + }), + ("workflow_route_provider_unavailable", |runtime| { + runtime.provider = None + }), + ("workflow_route_model_unavailable", |runtime| { + runtime.model = None + }), + ("workflow_route_reasoning_unavailable", |runtime| { + runtime.reasoning = None + }), + ("workflow_route_context_ceiling_unavailable", |runtime| { + runtime.context_window_tokens = None + }), + ("workflow_route_service_tier_mismatch", |runtime| { + runtime.service_tier = None + }), + ("workflow_route_auth_profile_mismatch", |runtime| { + runtime.auth_profile = None + }), + ("workflow_route_approval_profile_mismatch", |runtime| { + runtime.approval_policy = None + }), + ("workflow_route_permission_profile_mismatch", |runtime| { + runtime.permission_profile = None + }), + ]; + + for (expected_code, mutate) in cases { + let mut runtime = runtime(); + mutate(&mut runtime); + let provider_calls = AtomicUsize::new(0); + let error = invoke_provider_after_runtime_admission( + &exact_route(), + &runtime, + "isolated_worktree", + &provider_calls, + ) + .expect_err("unavailable runtime dimension must fail closed"); + assert_eq!(error.code(), expected_code); + assert_eq!(provider_calls.load(Ordering::SeqCst), 0); + } + + let provider_calls = AtomicUsize::new(0); + let error = invoke_provider_after_runtime_admission( + &exact_route(), + &runtime(), + "", + &provider_calls, + ) + .expect_err("missing worktree mode must fail closed"); + assert_eq!(error.code(), "workflow_route_worktree_mode_mismatch"); + assert_eq!(provider_calls.load(Ordering::SeqCst), 0); + } + + #[test] + fn missing_required_fallback_fails_closed() { + let mut route = exact_route(); + route + .routing + .as_mut() + .expect("routing contract") + .request + .constraints + .fallback_required = true; + + let provider_calls = AtomicUsize::new(0); + let error = + invoke_provider_after_route_admission(&route, &effective_route(), &provider_calls) + .expect_err("required fallback cannot be ignored"); + assert_eq!(error.code(), "workflow_route_required_fallback_missing"); + assert_eq!(provider_calls.load(Ordering::SeqCst), 0); + } + + #[test] + fn selected_route_cannot_claim_silent_fallback_use() { + let mut route = exact_route(); + let decision = route + .routing + .as_mut() + .expect("routing contract") + .decision + .as_mut() + .expect("routing decision"); + decision.fallback = Some(crate::WorkflowModelRoutingFallback { + used: true, + reason: Some("unadmitted alternate route".to_string()), + }); + let provider_calls = AtomicUsize::new(0); + + let error = + invoke_provider_after_route_admission(&route, &effective_route(), &provider_calls) + .expect_err("selected routes cannot silently claim fallback use"); + + assert_eq!(error.code(), "workflow_route_fallback_mismatch"); + assert_eq!(provider_calls.load(Ordering::SeqCst), 0); + } + + #[test] + fn explicit_required_fallback_route_can_invoke_provider() { + let mut route = exact_route(); + let routing = route.routing.as_mut().expect("routing contract"); + routing.request.constraints.fallback_required = true; + let decision = routing.decision.as_mut().expect("routing decision"); + decision.status = WorkflowModelRoutingDecisionStatus::Fallback; + decision.fallback = Some(crate::WorkflowModelRoutingFallback { + used: true, + reason: Some("preferred route unavailable".to_string()), + }); + let mut effective = effective_route(); + effective.fallback_used = true; + let provider_calls = AtomicUsize::new(0); + + invoke_provider_after_route_admission(&route, &effective, &provider_calls) + .expect("explicit required fallback is a supported immutable route"); + + assert_eq!(provider_calls.load(Ordering::SeqCst), 1); + } + + #[test] + fn finite_budget_without_provider_reservation_fails_before_provider_attempt() { + let mut route = exact_route(); + route + .routing + .as_mut() + .expect("routing contract") + .request + .constraints + .budget_usd = Some("5.00".to_string()); + let mut effective = effective_route(); + effective.credit_control = WorkflowProviderCreditControl::Unavailable; + + let provider_calls = AtomicUsize::new(0); + let error = invoke_provider_after_route_admission(&route, &effective, &provider_calls) + .expect_err("finite budgets require a provider reservation"); + assert_eq!(error.code(), "workflow_route_credit_ceiling_unavailable"); + assert_eq!(provider_calls.load(Ordering::SeqCst), 0); + } + + #[test] + fn exhausted_provider_credit_reservation_blocks_another_attempt_and_has_exact_readback() { + let mut route = exact_route(); + route + .routing + .as_mut() + .expect("routing contract") + .request + .constraints + .budget_usd = Some("5.00".to_string()); + let mut effective = effective_route(); + effective.credit_control = WorkflowProviderCreditControl::Reserved { + reservation_id: "reservation-1".to_string(), + ceiling_usd: "5.00".to_string(), + spent_usd: "5.00".to_string(), + remaining_usd: "0.00".to_string(), + exhausted: true, + }; + + let provider_calls = AtomicUsize::new(0); + let error = invoke_provider_after_route_admission(&route, &effective, &provider_calls) + .expect_err("an exhausted reservation cannot admit provider work"); + assert_eq!(error.code(), "workflow_route_credit_ceiling_exhausted"); + assert_eq!(provider_calls.load(Ordering::SeqCst), 0); + } + + #[test] + fn caller_supplied_credit_readback_cannot_authorize_billed_work() { + let mut route = exact_route(); + route + .routing + .as_mut() + .expect("routing contract") + .request + .constraints + .budget_usd = Some("5.00".to_string()); + let mut effective = effective_route(); + effective.credit_control = WorkflowProviderCreditControl::Reserved { + reservation_id: "reservation-1".to_string(), + ceiling_usd: "5.00".to_string(), + spent_usd: "1.25".to_string(), + remaining_usd: "3.75".to_string(), + exhausted: false, + }; + + let provider_calls = AtomicUsize::new(0); + let error = invoke_provider_after_route_admission(&route, &effective, &provider_calls) + .expect_err("caller-supplied accounting cannot stand in for a provider controller"); + + assert_eq!(error.code(), "workflow_route_credit_ceiling_unavailable"); + assert_eq!(provider_calls.load(Ordering::SeqCst), 0); + } + + #[test] + fn inconsistent_provider_credit_readback_fails_closed() { + let mut route = exact_route(); + route + .routing + .as_mut() + .expect("routing contract") + .request + .constraints + .budget_usd = Some("5.00".to_string()); + let mut effective = effective_route(); + effective.credit_control = WorkflowProviderCreditControl::Reserved { + reservation_id: "reservation-1".to_string(), + ceiling_usd: "5.00".to_string(), + spent_usd: "1.25".to_string(), + remaining_usd: "4.00".to_string(), + exhausted: false, + }; + let provider_calls = AtomicUsize::new(0); + + let error = invoke_provider_after_route_admission(&route, &effective, &provider_calls) + .expect_err("unbalanced provider accounting cannot admit work"); + + assert_eq!(error.code(), "workflow_route_credit_accounting_invalid"); + assert_eq!(provider_calls.load(Ordering::SeqCst), 0); + } +} diff --git a/codex-rs/workflows/src/spec.rs b/codex-rs/workflows/src/spec.rs index e0ea8d485..8197a98a4 100644 --- a/codex-rs/workflows/src/spec.rs +++ b/codex-rs/workflows/src/spec.rs @@ -65,11 +65,11 @@ pub struct WorkflowModelRoute { pub routing: Option, } -/// Policy-free contract that a future open-router integration can satisfy. +/// Policy-free routing contract enforced by workflow activation and provider attempts. /// -/// The surrounding [`WorkflowModelRoute`] remains exact and executable today. -/// This contract only records the request/decision boundary so Codewith -/// workflow specs can be validated without embedding provider ranking policy. +/// The surrounding [`WorkflowModelRoute`] and requested-versus-effective receipt stay exact +/// across retries, recovery, descendants, and verifier attempts. Routing policy remains outside +/// the spec, while unsupported enforcement surfaces fail closed before external effects. #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct WorkflowModelRoutingContract {