diff --git a/loopx/control_plane/collaboration/conversation_binding.ts b/loopx/control_plane/collaboration/conversation_binding.ts index bd0a0e7f9b..2c7fc465da 100644 --- a/loopx/control_plane/collaboration/conversation_binding.ts +++ b/loopx/control_plane/collaboration/conversation_binding.ts @@ -1,6 +1,7 @@ import type {JsonObject} from "../effect_program.ts"; import {isDeepStrictEqual} from "node:util"; import {createHash} from "node:crypto"; +import {BARE_SHA256_PATTERN} from "../content_digest.ts"; import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; import {requireInteger, requireJsonObject, requireNonEmptyString} from "../runtime_decode.ts"; import {requireAutomationCadenceEligibility} from "../quota/automation_cadence.ts"; @@ -611,7 +612,7 @@ function planContextRecovery(params: JsonObject, row: JsonObject, current: JsonO && Number.isFinite(created) && created >= start && created <= end && Number.isSafeInteger(proof.start_step_index) && Number(proof.start_step_index) >= 0 && Number.isSafeInteger(proof.total_steps) && Number(proof.total_steps) > Number(proof.start_step_index) + 1 - && [proof.input_sha256, proof.answer_sha256].every(value => typeof value === "string" && /^[a-f0-9]{64}$/.test(value)) + && [proof.input_sha256, proof.answer_sha256].every(value => typeof value === "string" && BARE_SHA256_PATTERN.test(value)) && response?.schema_version === "loopx_chat_agent_response_v0" && typeof response.message === "string" && response.message.trim().length > 0 && (adopted === null || adopted.turn_id === failed.turn_id diff --git a/tests/control_plane_ts/content_digest_single_owner.test.ts b/tests/control_plane_ts/content_digest_single_owner.test.ts index 7fdd69e9ae..90b2d1c931 100644 --- a/tests/control_plane_ts/content_digest_single_owner.test.ts +++ b/tests/control_plane_ts/content_digest_single_owner.test.ts @@ -108,6 +108,7 @@ const CANONICAL_CONSUMERS = [ "control_plane/capabilities/trajectory_input_usage.ts", "control_plane/capabilities/trajectory_tool_context.ts", "control_plane/collaboration/chat_mode.ts", + "control_plane/collaboration/conversation_binding.ts", "control_plane/collaboration/delegation.ts", "control_plane/collaboration/delegation_result_use.ts", "control_plane/collaboration/goal_instance_lifecycle.ts", diff --git a/tests/control_plane_ts/conversation_binding.test.ts b/tests/control_plane_ts/conversation_binding.test.ts index 2117ddd91d..ad25a868b2 100644 --- a/tests/control_plane_ts/conversation_binding.test.ts +++ b/tests/control_plane_ts/conversation_binding.test.ts @@ -45,6 +45,16 @@ test("native terminal recovery adopts a result without Turn admission or lost fa assert.equal(plan.operation, "adopt_native_result"); assert.equal(plan.recovery_eligible, true); assert.equal(plan.client_turn_id, undefined); + for (const field of ["input_sha256", "answer_sha256"]) { + for (const invalid of [null, false, 1, [], {}, "", "1".repeat(63), "1".repeat(65), + "A".repeat(64), `sha256:${"1".repeat(64)}`]) { + assert.equal(planBoundConversationRequest({...packet, context_recovery: {...packet.context_recovery, + native_observation: {...proof, [field]: invalid}}}).recovery_eligible, false, + `${field} must reject ${JSON.stringify(invalid)}`); + } + assert.equal(planBoundConversationRequest({...packet, context_recovery: {...packet.context_recovery, + native_observation: {...proof, [field]: "abcdef0123456789".repeat(4)}}}).recovery_eligible, true); + } for (const change of [{source_verified: false}, {stop_observed: true}, {steering_observed: true}, {attachments_present: true}, {native_observation: {...proof, thread_id: "other"}}, {native_observation: {...proof, request_ref: "a".repeat(24)}},