[], 'errno' => 0, 'status' => 200]; } function curl_setopt_array(object $handle, array $options): bool { $handle->options = $options; if (!str_ends_with($options[CURLOPT_URL], '/stop')) $GLOBALS['privateWireBodies'][] = $options[CURLOPT_POSTFIELDS]; return true; } function curl_exec(object $handle): bool|string { if (str_ends_with($handle->options[CURLOPT_URL], '/stop')) { ++$GLOBALS['privateWireStops']; return '{"result":"success"}'; } $scenario = array_shift($GLOBALS['privateWireScenarios']); if (!is_array($scenario)) throw new \RuntimeException('Unexpected synthetic transfer'); $handle->status = $scenario['status'] ?? 200; ($handle->options[CURLOPT_HEADERFUNCTION])($handle, 'HTTP/1.1 ' . $handle->status . " OK\r\n"); foreach ($scenario['chunks'] as $chunk) { if (($handle->options[CURLOPT_WRITEFUNCTION])($handle, $chunk) !== strlen($chunk)) { $handle->errno = CURLE_WRITE_ERROR; return false; } } return true; } function curl_errno(object $handle): int { return $handle->errno; } function curl_getinfo(object $handle, int $option): int|float { return $option === CURLINFO_HTTP_CODE ? $handle->status : 0; } function curl_close(object $handle): void { ++$GLOBALS['privateWireClosed']; } } namespace { require dirname(__DIR__) . '/vendor/autoload.php'; require dirname(__DIR__, 2) . '/app/artifacts/ai_concurrency/private_tcm_r25_wire_audit.php'; use app\common\service\DifyChatService; use app\adminapi\service\AssistantRequestControl; use think\facade\Log; $checks = 0; $privateWireBodies = []; $privateWireScenarios = []; $privateWireClosed = 0; $privateWireStops = 0; $privateWireConfig = ['enable' => true, 'base_url' => 'https://wire.synthetic.invalid/v1/chat-messages', 'timeout' => 90, 'models' => ['qwen' => ['name' => 'synthetic-model', 'api_key' => 'private-never-sent-key']]]; $expect = static function (bool $value, string $message) use (&$checks): void { ++$checks; if (!$value) throw new RuntimeException($message); }; $frame = static fn (array $event): string => 'data: ' . json_encode($event, JSON_THROW_ON_ERROR) . "\n\n"; $scenario = static function (string $task = 'synthetic-task', string $run = 'synthetic-run') use ($frame): array { return ['chunks' => [ $frame(['event' => 'node_started', 'task_id' => $task]), $frame(['event' => 'workflow_started', 'task_id' => $task, 'workflow_run_id' => $run, 'data' => ['id' => $run]]), $frame(['event' => 'workflow_started', 'task_id' => $task, 'workflow_run_id' => $run, 'data' => ['id' => $run]]), $frame(['event' => 'workflow_started', 'task_id' => 'foreign-task', 'workflow_run_id' => 'foreign-run']), $frame(['event' => 'workflow_started', 'task_id' => $task, 'workflow_run_id' => 'changed-run']), $frame(['event' => 'message', 'task_id' => $task, 'answer' => 'synthetic-answer']), $frame(['event' => 'message_end', 'task_id' => $task]), $frame(['event' => 'workflow_finished', 'task_id' => $task, 'workflow_run_id' => $run, 'data' => ['id' => $run, 'status' => 'succeeded']]), ]]; }; $invoke = static fn (string $method, array $args): mixed => (new ReflectionMethod(DifyChatService::class, $method))->invokeArgs(null, $args); $directory = sys_get_temp_dir() . '/tcm-r25-wire-test-' . bin2hex(random_bytes(8)); mkdir($directory, 0700); $requestNumber = 0; $audit = static function () use (&$requestNumber, $directory): array { $id = sprintf('00000000-0000-4000-8000-%012d', ++$requestNumber); return [new PrivateTcmR25WireAudit($directory, $id, 'synthetic_test_' . $requestNumber, 'qwen'), $directory . '/' . hash('sha256', $id) . '.private.jsonl', $id]; }; $records = static function (string $path): array { return array_map(static fn (string $line): array => json_decode($line, true, 32, JSON_THROW_ON_ERROR), file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES)); }; $cleanState = ['transport_closed' => true, 'upstream_terminal_confirmed' => true, 'upstream_stop_confirmed' => true, 'cancel_requested' => false, 'timeout_reached' => false, 'upstream_pending_task_count' => 0]; try { foreach (["plain-query", "中文 / path \\ \"quoted\"\nline", "invalid-utf8-\xff", "initial\n\n{}\n"] as $query) { [$trace, $path] = $audit(); $captured = []; $controlTasks = []; $terminal = []; $seenBodyCount = count($privateWireBodies); $privateWireScenarios = [$scenario()]; $reply = DifyChatService::streamChat('qwen', ['clinical' => "private-input-text/中文"], $query, 'private-user', static fn (): bool => true, null, [], ['knowledge_mode' => 'evidence', 'on_private_wire_request' => static function (array $wire) use ($trace, &$captured, $expect, $seenBodyCount): ?callable { $expect(count($GLOBALS['privateWireBodies']) === $seenBodyCount + 1, 'wire observer runs after actual POSTFIELDS assignment'); $captured[] = $wire; return $trace->observe($wire); }, 'on_task' => static function (string $task, string $run = '') use (&$controlTasks): void { $controlTasks[] = [$task, $run]; }, 'on_terminal' => static function (string $task, string $status, string $run = '') use (&$terminal): void { $terminal[] = [$task, $status, $run]; }]); $summary = $trace->finish($cleanState); $body = $privateWireBodies[array_key_last($privateWireBodies)]; $wire = json_decode($body, false, 512, JSON_THROW_ON_ERROR); $expectedInputs = json_encode($wire->inputs, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR); $expect($captured[0]['body_sha256'] === hash('sha256', $body) && $captured[0]['body_bytes'] === strlen($body), 'exact assigned serialized body fingerprint'); $expect($captured[0]['query_sha256'] === hash('sha256', $wire->query) && $captured[0]['query_bytes'] === strlen($wire->query), 'actual post-serialization query fingerprint including UTF-8 substitution'); $expect($captured[0]['inputs_sha256'] === hash('sha256', $expectedInputs) && $captured[0]['inputs_bytes'] === strlen($expectedInputs), 'actual serialized inputs fingerprint'); $expect(!empty($reply['ok']) && $controlTasks === [['synthetic-task', ''], ['synthetic-task', 'synthetic-run']] && $terminal === [['synthetic-task', 'succeeded', 'synthetic-run']], 'duplicates and foreign metadata preserve normal identity and terminal behavior'); $rows = $records($path); $expect($summary['persisted'] && $summary['audit_sha256'] === hash_file('sha256', $path) && $summary['original_task_count'] === 1 && $summary['original_run_count'] === 1, 'audit persists one original task and immutable enrichment'); $expect($rows[1]['report_stage'] === (str_contains($wire->query, "\n\n") ? 2 : 1) && $rows[1]['stage_attempt'] === 1 && $rows[2]['generation'] === $rows[1]['generation'] && $rows[3]['generation'] === $rows[1]['generation'], 'stage derives from actual serialized query and identity uses captured generation directly'); $privateText = file_get_contents($path); foreach (['private-input-text', 'private-user', 'private-never-sent-key', 'synthetic-answer', 'foreign-task', 'changed-run'] as $secret) $expect(!str_contains($privateText, $secret), 'private audit excludes text/credentials and foreign identity'); $expect(!str_contains(json_encode([$reply, $summary, Log::$lines]), 'synthetic-task') && !str_contains(json_encode([$reply, $summary, Log::$lines]), 'synthetic-run'), 'safe results/logs contain no original identity'); } [$trace, $path] = $audit(); $captured = []; $offset = count($privateWireBodies); $privateWireScenarios = [['status' => 400, 'chunks' => ['{"code":"invalid_inputs","message":"private-provider-error"}']], $scenario('retry-task', 'retry-run')]; $reply = DifyChatService::streamChat('qwen', ['required' => 'private-input'], 'full-retry-query/中文', 'private-user', static fn (): bool => true, null, [], ['knowledge_mode' => 'evidence', 'on_private_wire_request' => static function (array $wire) use ($trace, &$captured): ?callable { $captured[] = $wire; return $trace->observe($wire); }]); $summary = $trace->finish($cleanState); $rows = $records($path); $serialized = array_values(array_filter($rows, static fn (array $row): bool => $row['event'] === 'serialized_request')); $identities = array_values(array_filter($rows, static fn (array $row): bool => $row['event'] === 'original_identity')); $expect(!empty($reply['ok']) && count($captured) === 2 && $summary['serialized_request_count'] === 2, 'actual inputs_invalid compatibility retry is separately audited'); $expect($captured[0]['query_sha256'] === $captured[1]['query_sha256'] && $captured[0]['inputs_sha256'] !== $captured[1]['inputs_sha256'] && $captured[1]['inputs_sha256'] === hash('sha256', '{}') && $captured[1]['inputs_bytes'] === 2, 'retry retains full query but distinguishes exact empty-object inputs'); $expect(array_column($serialized, 'stage_attempt') === [1, 2] && array_column($serialized, 'generation') === [1, 2] && array_unique(array_column($identities, 'generation')) === [2], 'retry run links only to its own serialized request without neighbor inference'); // The real private control releases its task/run after confirmed cleanup; // the independent audit remains readable with its original pair intact. [$trace, $path, $requestId] = $audit(); $control = new AssistantRequestControl(991, 11, $requestId, $directory . '/controls'); $expect($control->begin(), 'synthetic request control begins'); $privateWireScenarios = [$scenario('cleanup-task', 'cleanup-run')]; $reply = DifyChatService::streamChat('qwen', [], 'cleanup-query', 'private-user', static fn (): bool => true, null, [], ['knowledge_mode' => 'evidence', 'on_private_wire_request' => [$trace, 'observe'], 'on_task' => static fn (string $task, string $run = '') => $control->bindTask('qwen', 'private-user', $task, $run), 'on_terminal' => static fn (string $task, string $status, string $run = '') => $control->markTerminal($task, $status, $run), 'on_transfer_end' => static fn (string $task) => $control->clearTask($task)]); $control->finish('done'); $summary = $trace->finish($control->publicState()); $rows = $records($path); $last = $rows[array_key_last($rows)]; $controlPath = $directory . '/controls/' . hash('sha256', '991:11:' . $requestId) . '.json'; $expect(!empty($reply['ok']) && $control->publicState()['upstream_pending_task_count'] === 0 && $last['event'] === 'after_control_cleanup' && $last['state']['upstream_pending_task_count'] === 0 && $summary['audit_sha256'] === hash_file('sha256', $path), 'successful real control cleanup leaves independent immutable private trace'); $expect(str_contains(file_get_contents($path), 'cleanup-task') && str_contains(file_get_contents($path), 'cleanup-run'), 'original exact pair retained after cleanup'); $expect(!str_contains(file_get_contents($controlPath), 'cleanup-task') && !str_contains(file_get_contents($controlPath), 'cleanup-run'), 'successful control actually released the original identity'); $expect($rows[0]['request_id'] === $requestId && $rows[0]['request_label'] === 'synthetic_test_' . $requestNumber, 'private header retains exact own label-to-request association'); [$trace, $path] = $audit(); foreach (['initial-stage', "initial-stage\n\n{}\n"] as $number => $query) { $privateWireScenarios = [$scenario('stage-task-' . $number, 'stage-run-' . $number)]; $reply = DifyChatService::streamChat('qwen', [], $query, 'private-user', static fn (): bool => true, null, [], ['knowledge_mode' => 'evidence', 'on_private_wire_request' => [$trace, 'observe']]); $expect(!empty($reply['ok']), 'initial and continuation keep normal delivery'); } $summary = $trace->finish($cleanState); $rows = $records($path); $serialized = array_values(array_filter($rows, static fn (array $row): bool => $row['event'] === 'serialized_request')); $identities = array_values(array_filter($rows, static fn (array $row): bool => $row['event'] === 'original_identity' && $row['original_workflow_run_id'] !== '')); $expect(array_column($serialized, 'report_stage') === [1, 2] && array_column($serialized, 'stage_attempt') === [1, 1] && array_column($identities, 'original_workflow_run_id') === ['stage-run-0', 'stage-run-1'] && array_column($identities, 'generation') === [1, 2], 'one request retains each original run with its own explicit report stage and attempt'); // The closure is immutable even if independently called out of order. [$trace, $path, $requestId] = $audit(); $sample = $captured[0]; $first = $trace->observe($sample); $second = $trace->observe($sample); $second('second-task', ''); $first('first-task', ''); $second('second-task', 'second-run'); $first('first-task', 'first-run'); $first('foreign-task', 'foreign-run'); $first('first-task', 'changed-run'); $first('first-task', 'first-run'); $first("bad\ntask", 'first-run'); $first('first-task', ''); $summary = $trace->finish($cleanState); $before = file_get_contents($path); $first('first-task', 'later-run'); $duplicate = new PrivateTcmR25WireAudit($directory, $requestId, 'different_label', 'qwen'); $expect(!$duplicate->finish($cleanState)['persisted'] && file_get_contents($path) === $before, 'exclusive create and finalization forbid overwrite or replay'); $identities = array_values(array_filter($records($path), static fn (array $row): bool => $row['event'] === 'original_identity')); $expect(array_column($identities, 'generation') === [2, 1, 2, 1] && $identities[2]['original_workflow_run_id'] === 'second-run' && $identities[3]['original_workflow_run_id'] === 'first-run' && $summary['original_run_count'] === 2, 'out-of-order callbacks retain captured generation and immutable same-task runs'); $expect(!str_contains($before, 'foreign-task') && !str_contains($before, 'changed-run') && !str_contains($before, 'bad'), 'wrong IDs and duplicate enrichment do not poison audit'); foreach (['request_throw', 'identity_throw', 'return_invalid', 'disabled'] as $mode) { $privateWireScenarios = [$scenario()]; $observed = 0; $controlTasks = []; $options = ['knowledge_mode' => 'evidence', 'on_task' => static function (string $task, string $run = '') use (&$controlTasks): void { $controlTasks[] = [$task, $run]; }]; if ($mode !== 'disabled') $options['on_private_wire_request'] = static function (array $wire) use ($mode, &$observed): mixed { ++$observed; if ($mode === 'request_throw') throw new RuntimeException('private-observer-failure'); if ($mode === 'return_invalid') return new stdClass(); return static function () use (&$observed): void { ++$observed; throw new RuntimeException('private-identity-failure'); }; }; $started = hrtime(true); $reply = DifyChatService::streamChat('qwen', [], 'same-query', 'private-user', static fn (): bool => true, null, [], $options); $expect(!empty($reply['ok']) && count($controlTasks) === 2 && (hrtime(true) - $started) / 1e6 < 1000, 'private observer failure is bounded and leaves original control/delivery behavior intact'); $expect($observed === ($mode === 'disabled' ? 0 : ($mode === 'identity_throw' ? 3 : 1)), 'private observer has no retry or duplicate enrichment callbacks'); $expect(!str_contains(json_encode([$reply, Log::$lines]), 'private-observer-failure') && !str_contains(json_encode([$reply, Log::$lines]), 'private-identity-failure'), 'observer exceptions never enter result/log'); } [$trace, $path] = $audit(); $privateWireScenarios = [$scenario()]; $result = $invoke('sendStreamRequest', ['dify', 'https://wire.synthetic.invalid/chat-messages', ['inputs' => (object) [], 'query' => 'bounded'], 'private-key', 90, static fn (): bool => true, null, ['on_private_wire_request' => [$trace, 'observe'], 'on_task' => static function (): void { throw new RuntimeException('private-control-persist-failure'); }]]); $summary = $trace->finish([]); $expect($result['callback_error'] && !$result['upstream_terminal_confirmed'] && $summary['original_task_count'] === 1, 'private tracing cannot clear the existing fail-closed control observer latch'); $expect(!str_contains(json_encode([$result, $summary, Log::$lines]), 'private-control-persist-failure'), 'control callback exception text stays private'); [$trace, $path] = $audit(); for ($index = 0; $index < 16; ++$index) $expect(is_callable($trace->observe($sample)), 'finite per-request generation capacity'); $expect($trace->observe($sample) === null && !$trace->finish([])['persisted'] && filesize($path) <= 65536, 'capacity overflow disables audit without unbounded writes'); [$trace, $path] = $audit(); $expect($trace->observe($sample + ['raw_query' => 'must-never-persist']) === null && !$trace->finish([])['persisted'] && !str_contains(file_get_contents($path), 'must-never-persist'), 'closed metadata allowlist rejects raw text injection'); $payload = ['model' => 'synthetic', 'messages' => [['role' => 'user', 'content' => 'private-openai-text']], 'stream' => true]; $privateWireScenarios = [['chunks' => ['data: {"choices":[{"delta":{"content":"answer"}}]}' . "\n\n", "data: [DONE]\n\n"]]]; $meta = null; $invoke('sendStreamRequest', ['openai', 'https://wire.synthetic.invalid/chat/completions', $payload, 'private-key', 90, static fn (): bool => true, null, ['on_private_wire_request' => static function (array $wire) use (&$meta): void { $meta = $wire; }]]); $expect($meta['protocol'] === 'openai' && $meta['query_sha256'] === null && $meta['inputs_sha256'] === null && $meta['body_sha256'] === hash('sha256', $privateWireBodies[array_key_last($privateWireBodies)]), 'alternate protocol truthfully marks absent query/inputs and retains exact body digest'); $observed = 0; $beforeBodies = count($privateWireBodies); $expired = $invoke('sendStreamRequest', ['dify', 'https://wire.synthetic.invalid/chat-messages', ['query' => 'never-submit'], 'private-key', 90, static fn (): bool => true, null, ['request_deadline_ns' => hrtime(true) - 1, 'on_private_wire_request' => static function () use (&$observed): void { ++$observed; }]]); $expect($expired['errno'] === CURLE_OPERATION_TIMEDOUT && $observed === 0 && count($privateWireBodies) === $beforeBodies, 'expired ordinary deadline performs no serialization, private observation or transfer'); $expect($privateWireScenarios === [], 'all and only declared stub transfers consumed'); echo "Dify private wire audit: {$checks} assertions passed (in-memory transport, private synthetic files, no network).\n"; } finally { // Only this test's freshly-created, resolved temporary directory is removed. $real = realpath($directory); $base = realpath(sys_get_temp_dir()); if ($real === false || $base === false || !str_starts_with(str_replace('\\', '/', $real), rtrim(str_replace('\\', '/', $base), '/') . '/tcm-r25-wire-test-')) throw new RuntimeException('Synthetic cleanup boundary'); $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($real, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST); foreach ($files as $file) { if ($file->isLink()) throw new RuntimeException('Synthetic cleanup symlink'); if ($file->isDir()) rmdir($file->getPathname()); else unlink($file->getPathname()); } rmdir($real); } }