$scenario, 'options' => [], 'errno' => 0]; } function curl_setopt_array(object $handle, array $options): bool { $handle->options = $options; $GLOBALS['inspectOptions'][] = $options; return true; } function curl_exec(object $handle): bool { if (!empty($handle->scenario['throw'])) throw new \RuntimeException('private-provider-error'); $GLOBALS['inspectClock'] += $handle->scenario['advance'] ?? 0; $handle->errno = $handle->scenario['errno'] ?? 0; foreach ($handle->scenario['chunks'] ?? [$handle->scenario['body']] as $chunk) { if (($handle->options[CURLOPT_WRITEFUNCTION])($handle, $chunk) !== strlen($chunk)) { $handle->errno = CURLE_WRITE_ERROR; return false; } } return $handle->errno === 0; } function curl_errno(object $handle): int { return $handle->errno; } function curl_getinfo(object $handle, int $option): int { return $handle->scenario['http'] ?? 200; } function curl_close(object $handle): void { ++$GLOBALS['inspectClosed']; } } namespace { require dirname(__DIR__, 2) . '/app/artifacts/ai_concurrency/private_tcm_r25_wire_audit.php'; require dirname(__DIR__, 2) . '/app/artifacts/ai_concurrency/private_tcm_r25_run_input_inspector.php'; use PrivateTcmR25\RunInputInspector; $checks = 0; $inspectClock = 10000000000; $inspectCalls = 0; $inspectClosed = 0; $inspectOptions = []; $inspectScenarios = []; $expect = static function (bool $ok, string $message) use (&$checks): void { ++$checks; if (!$ok) throw new RuntimeException($message); }; $base = sys_get_temp_dir() . '/tcm-r25-input-inspector-test-' . bin2hex(random_bytes(8)); $root = $base . '/server'; $directory = $root . '/runtime/tcm-r25-wire-audits'; mkdir($directory, 0700, true); $resultDirectory = $base . '/app/artifacts/ai_concurrency'; mkdir($resultDirectory, 0700, true); $number = 0; $closed = ['transport_closed' => true, 'upstream_terminal_confirmed' => true, 'upstream_stop_confirmed' => true, 'cancel_requested' => false, 'timeout_reached' => false, 'upstream_pending_task_count' => 0]; $make = static function (array $queries, ?array $pairs = null, string $profile = 'qwen') use (&$number, $directory, $closed): array { $id = sprintf('22222222-2222-4222-8222-%012d', ++$number); $trace = new PrivateTcmR25WireAudit($directory, $id, 'actual_row_' . $profile, $profile); foreach ($queries as $offset => $query) { $identity = $trace->observe(['protocol' => 'dify', 'body_sha256' => hash('sha256', 'body-' . $query), 'body_bytes' => strlen('body-' . $query), 'query_sha256' => hash('sha256', $query), 'query_bytes' => strlen($query), 'inputs_sha256' => hash('sha256', '{}'), 'inputs_bytes' => 2, 'report_continuation_query' => $offset > 0]); $pair = $pairs === null ? ['private-task-' . $offset, 'private-run-' . $offset] : ($pairs[$offset] ?? ['', '']); if ($pair[0] !== '') { $identity($pair[0], ''); if ($pair[1] !== '') $identity(...$pair); } } $summary = $trace->finish($closed); return [['test_label' => 'actual_row', 'profile' => $profile, 'private_wire_audit' => $summary], $directory . '/' . hash('sha256', $id) . '.private.jsonl']; }; $rewrite = static function (array $fixture, callable $change): array { [$selector, $path] = $fixture; $rows = array_map(static fn (string $line): array => json_decode($line, true, 16, JSON_THROW_ON_ERROR), file($path, FILE_IGNORE_NEW_LINES)); $rows = $change($rows); $content = implode("\n", array_map(static fn (array $row): string => json_encode($row, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), $rows)) . "\n"; file_put_contents($path, $content); $selector['private_wire_audit']['audit_sha256'] = hash('sha256', $content); $selector['private_wire_audit']['audit_bytes'] = strlen($content); return [$selector, $path]; }; $config = ['enable' => true, 'base_url' => 'https://dify.synthetic.invalid/v1/chat-messages', 'models' => [ 'qwen' => ['api_key' => 'private-qwen-key'], 'openai' => ['api_key' => 'private-openai-key']]]; $query = "[CASE_DATA] 完整原文/quoted\"\n\n不改写"; $response = static fn (string $value, int $n = 0): array => ['id' => 'private-run-' . $n, 'status' => 'succeeded', 'inputs' => ['sys.query' => $value], 'outputs' => ['private-output' => 'must-not-escape']]; $run = static function (array $selector, array $responses, ?array $custom = null) use ($root, $config): array { $GLOBALS['inspectScenarios'] = $responses; return RunInputInspector::inspect($root, $selector, static fn (): array => $custom ?? $config); }; $json = static fn (array $response): string => json_encode($response, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); try { [$selector] = $make([$query, $query . '续写']); $answer = $run($selector, [['body' => $json($response($query))], ['body' => $json($response($query . '续写', 1))]]); $expect($answer['ok'] && $answer['matched_run_count'] === 2 && $answer['inspected_run_count'] === 2, 'exact full-audit digest selects both original per-generation runs'); $expect(!$answer['llm_expanded_prompt_observed'] && $answer['model_calls'] === 0, 'top-level run input match is not LLM visibility or inference'); $expect($answer['audit_sha256'] === $selector['private_wire_audit']['audit_sha256'], 'safe result binds the whole original audit digest'); foreach ($inspectOptions as $options) { $expect($options[CURLOPT_HTTPGET] === true && !isset($options[CURLOPT_POSTFIELDS]) && !isset($options[CURLOPT_POST]), 'only GET and no inference POST'); $expect($options[CURLOPT_SSL_VERIFYPEER] === true && $options[CURLOPT_SSL_VERIFYHOST] === 2 && $options[CURLOPT_FOLLOWLOCATION] === false && $options[CURLOPT_MAXREDIRS] === 0, 'verified TLS and no redirect'); $expect($options[CURLOPT_PROXY] === '' && $options[CURLOPT_NOPROXY] === '*' && $options[CURLOPT_NETRC] === CURL_NETRC_IGNORED, 'environment proxy/netrc disabled'); $expect($options[CURLOPT_TIMEOUT_MS] <= 2000 && $options[CURLOPT_TIMEOUT_MS] > 0 && $options[CURLOPT_CONNECTTIMEOUT_MS] <= 2000, 'per-original-run timeout capped at 2s'); $expect($options[CURLOPT_PROTOCOLS] === CURLPROTO_HTTPS && $options[CURLOPT_REDIR_PROTOCOLS] === CURLPROTO_HTTPS, 'HTTPS configured root remains pinned to HTTPS'); } $expect(str_ends_with($inspectOptions[0][CURLOPT_URL], '/v1/workflows/run/private-run-0') && str_ends_with($inspectOptions[1][CURLOPT_URL], '/v1/workflows/run/private-run-1'), 'supported original exact-run endpoint only'); $safe = json_encode($answer); foreach (['private-task', 'private-run', 'private-qwen-key', 'dify.synthetic', 'CASE_DATA', 'must-not-escape', 'actual_row'] as $private) $expect(!str_contains($safe, $private), 'safe output excludes original IDs/raw text/config/labels'); $resultPath = $resultDirectory . '/tcm_complete_synthetic_inspector.safe.json'; $savedRow = $selector + ['harness_contract' => 'v43r25_private_run_wire', 'synthetic' => true, 'preparation_isolated' => true, 'preparation_isolation_verified' => true, 'production_acceptance_path' => true, 'diagnostic_only' => false, 'case_id' => 11, 'production_hashes' => ['fixture' => 'b54c47ec11f6a99dc87bdda78629e18c8a6f37a64017e42a72db399c727cb1c6', 'question' => '28fbaee8585d4fbde3be3b1a6ee046c1a60f18a1fe67c0769211b08afdd5d306']]; $savedDocument = ['harness_contract' => 'v43r25_private_run_wire', 'synthetic_only' => true, 'preparation_isolated' => true, 'fixture_sha256' => 'b54c47ec11f6a99dc87bdda78629e18c8a6f37a64017e42a72db399c727cb1c6', 'canonical_question_sha256' => '28fbaee8585d4fbde3be3b1a6ee046c1a60f18a1fe67c0769211b08afdd5d306', 'rows' => [$savedRow]]; file_put_contents($resultPath, $json($savedDocument)); $savedSelector = ['result_file' => basename($resultPath), 'test_label' => 'actual_row', 'profile' => 'qwen', 'audit_sha256' => $selector['private_wire_audit']['audit_sha256']]; $inspectScenarios = [['body' => $json($response($query))], ['body' => $json($response($query . '续写', 1))]]; $answer = RunInputInspector::inspectResultRow($root, $savedSelector, static fn (): array => $config); $expect($answer['ok'] && $answer['result_file_sha256'] === hash_file('sha256', $resultPath), 'live entry proves digest in one exact actual saved row before audit selection'); foreach (['missing_file', 'path_override', 'wrong_digest', 'missing_row', 'duplicate_row', 'old_contract', 'wrong_fixture', 'unverified_row', 'wrong_row_hash', 'partial_json', 'oversize_file'] as $kind) { $selected = $savedSelector; $document = $savedDocument; if ($kind === 'missing_file') $selected['result_file'] = 'tcm_complete_missing.safe.json'; if ($kind === 'path_override') $selected['result_file'] = '../tcm_complete_synthetic_inspector.safe.json'; if ($kind === 'wrong_digest') $selected['audit_sha256'] = str_repeat('a', 64); if ($kind === 'missing_row') $selected['test_label'] = 'neighbor'; if ($kind === 'duplicate_row') $document['rows'][] = $savedRow; if ($kind === 'old_contract') $document['harness_contract'] = 'v43r24_run_aware'; if ($kind === 'wrong_fixture') $document['fixture_sha256'] = str_repeat('b', 64); if ($kind === 'unverified_row') $document['rows'][0]['preparation_isolation_verified'] = false; if ($kind === 'wrong_row_hash') $document['rows'][0]['production_hashes']['question'] = str_repeat('b', 64); file_put_contents($resultPath, $kind === 'partial_json' ? '{' : ($kind === 'oversize_file' ? str_repeat('x', 2097153) : $json($document))); $before = $inspectCalls; $configurationCalls = 0; $answer = RunInputInspector::inspectResultRow($root, $selected, static function () use (&$configurationCalls, $config): array { ++$configurationCalls; return $config; }); $expect(!$answer['ok'] && $inspectCalls === $before && $configurationCalls === 0, 'actual saved-row boundary fails before config/GET: ' . $kind); } file_put_contents($resultPath, $json($savedDocument)); foreach (['same_both', 'query_only', 'mismatch', 'nested', 'serialized_inputs', 'conflict', 'invalid_query', 'wrong_run', 'wrong_task', 'bad_status'] as $kind) { [$selected] = $make([$query]); $value = $response($query); if ($kind === 'same_both') $value['inputs']['query'] = $query; if ($kind === 'query_only') $value['inputs'] = ['query' => $query]; if ($kind === 'mismatch') $value['inputs']['sys.query'] .= "\n"; if ($kind === 'nested') $value['inputs'] = ['nested' => ['sys.query' => $query]]; if ($kind === 'serialized_inputs') $value['inputs'] = json_encode(['sys.query' => $query]); if ($kind === 'conflict') $value['inputs']['query'] = 'other'; if ($kind === 'invalid_query') $value['inputs']['query'] = []; if ($kind === 'wrong_run') $value['id'] = 'wrong-private-run'; if ($kind === 'wrong_task') $value['task_id'] = 'wrong-private-task'; if ($kind === 'bad_status') $value['status'] = 'unknown'; $answer = $run($selected, [['body' => $json($value)]]); $expect($answer['ok'] === in_array($kind, ['same_both', 'query_only'], true), 'whitelisted exact input-path/identity/status boundary: ' . $kind); $expect(!str_contains(json_encode($answer), 'CASE_DATA') && !str_contains(json_encode($answer), 'private-run'), 'response content remains private: ' . $kind); } foreach (['wrong_digest', 'bad_summary', 'wrong_label', 'wrong_profile', 'partial', 'wrong_uuid', 'bad_identity', 'duplicate_identity', 'wrong_stage', 'extra_raw', 'pending', 'task_only', 'compat_missing', 'too_many', 'duplicate_run', 'path_override', 'summary_count'] as $kind) { $queries = $kind === 'too_many' ? [$query, $query, $query] : ($kind === 'compat_missing' || $kind === 'duplicate_run' ? [$query, $query] : [$query]); $pairs = match ($kind) { 'task_only' => [['private-task-0', '']], 'compat_missing' => [['', ''], ['private-task-1', 'private-run-1']], 'duplicate_run' => [['private-task-0', 'shared-run'], ['private-task-1', 'shared-run']], default => null, }; $fixture = $make($queries, $pairs); if (in_array($kind, ['partial', 'wrong_uuid', 'bad_identity', 'duplicate_identity', 'wrong_stage', 'extra_raw', 'pending'], true)) { $fixture = $rewrite($fixture, static function (array $rows) use ($kind): array { if ($kind === 'partial') array_pop($rows); if ($kind === 'wrong_uuid') $rows[0]['request_id'] = '33333333-3333-4333-8333-333333333333'; if ($kind === 'bad_identity') $rows[2]['original_task_id'] = 'bad/task'; if ($kind === 'duplicate_identity') array_splice($rows, 3, 0, [$rows[2]]); if ($kind === 'wrong_stage') $rows[1]['stage_attempt'] = 2; if ($kind === 'extra_raw') $rows[1]['raw_query'] = 'must-not-escape'; if ($kind === 'pending') $rows[count($rows) - 1]['state']['upstream_pending_task_count'] = 1; return $rows; }); } [$selected] = $fixture; if ($kind === 'wrong_digest') $selected['private_wire_audit']['audit_sha256'] = str_repeat('a', 64); if ($kind === 'bad_summary') $selected['private_wire_audit']['original_task_id'] = 'must-not-escape'; if ($kind === 'wrong_label') $selected['test_label'] = 'neighbor'; if ($kind === 'wrong_profile') $selected['profile'] = 'openai'; if ($kind === 'path_override') $selected['path'] = '../private'; if ($kind === 'summary_count') ++$selected['private_wire_audit']['original_run_count']; $before = $inspectCalls; $configurationCalls = 0; $answer = RunInputInspector::inspect($root, $selected, static function () use (&$configurationCalls, $config): array { ++$configurationCalls; return $config; }); $expect(!$answer['ok'] && $inspectCalls === $before && $configurationCalls === 0, 'bad/partial/ambiguous audit fails before config/GET: ' . $kind); } [$selected, $originalPath] = $make([$query]); $copyPath = $directory . '/' . str_repeat('f', 64) . '.private.jsonl'; copy($originalPath, $copyPath); $before = $inspectCalls; $answer = $run($selected, []); $expect(!$answer['ok'] && $answer['status'] === 'audit_digest_ambiguous' && $inspectCalls === $before, 'duplicate full digest never selects a neighboring file'); unlink($copyPath); [$selected] = $make([$query], [[str_repeat('t', 128), str_repeat('r', 128)]]); $value = $response($query); $value['id'] = str_repeat('r', 128); $value['task_id'] = str_repeat('t', 128); $answer = $run($selected, [['body' => $json($value)]]); $expect($answer['ok'], 'exact 128 ASCII task/run identity accepted without widening'); [$selected] = $rewrite($make([$query]), static function (array $rows): array { $rows[3]['original_workflow_run_id'] = str_repeat('r', 129); return $rows; }); $before = $inspectCalls; $answer = $run($selected, []); $expect(!$answer['ok'] && $inspectCalls === $before, '129 ASCII run identity rejected before GET'); foreach (['redirect', 'transport', 'throw', 'timeout', 'invalid_json', 'over_limit', 'exact_limit'] as $kind) { [$selected] = $make([$query]); $scenario = ['body' => $json($response($query))]; if ($kind === 'redirect') $scenario['http'] = 302; if ($kind === 'transport') $scenario['errno'] = 60; if ($kind === 'throw') $scenario['throw'] = true; if ($kind === 'timeout') $scenario['advance'] = 2000000000; if ($kind === 'invalid_json') $scenario['body'] = '{private-invalid-json'; if ($kind === 'over_limit') $scenario['body'] = str_repeat('x', 2097153); if ($kind === 'exact_limit') { $value = $response($query); $value['outputs'] = ['padding' => '']; $value['outputs']['padding'] = str_repeat('x', 2097152 - strlen($json($value))); $scenario['body'] = $json($value); $expect(strlen($scenario['body']) === 2097152, 'exact response cap fixture'); } $answer = $run($selected, [$scenario]); $expect($answer['ok'] === ($kind === 'exact_limit'), 'bounded GET error/size/deadline handling: ' . $kind); } [$selected] = $make([$query], null, 'openai'); $answer = $run($selected, [['body' => $json($response($query))]]); $expect($answer['ok'] && in_array('Authorization: Bearer private-openai-key', $inspectOptions[array_key_last($inspectOptions)][CURLOPT_HTTPHEADER], true), 'same original profile service key only'); // Match the original deployed-channel contract, including an explicit // HTTP port: use exactly that configured scheme/path/key, no fallback. $httpConfig = $config; $httpConfig['base_url'] = 'http://dify.synthetic.invalid:8088/v1/chat-messages'; [$selected] = $make([$query]); $before = $inspectCalls; $answer = $run($selected, [['body' => $json($response($query))]], $httpConfig); $httpOptions = $inspectOptions[array_key_last($inspectOptions)]; $expect($answer['ok'] && $inspectCalls === $before + 1, 'configured HTTP root is accepted with exactly one original GET'); $expect($httpOptions[CURLOPT_URL] === 'http://dify.synthetic.invalid:8088/v1/workflows/run/private-run-0' && in_array('Authorization: Bearer private-qwen-key', $httpOptions[CURLOPT_HTTPHEADER], true), 'exact configured HTTP port/path/profile key retained'); $expect($httpOptions[CURLOPT_PROTOCOLS] === CURLPROTO_HTTP && $httpOptions[CURLOPT_REDIR_PROTOCOLS] === CURLPROTO_HTTP, 'configured HTTP does not silently upgrade or widen protocol mask'); $expect($httpOptions[CURLOPT_FOLLOWLOCATION] === false && $httpOptions[CURLOPT_MAXREDIRS] === 0 && $httpOptions[CURLOPT_PROXY] === '' && $httpOptions[CURLOPT_NOPROXY] === '*' && $httpOptions[CURLOPT_NETRC] === CURL_NETRC_IGNORED, 'HTTP retains no redirect/environment proxy/netrc behavior'); $before = $inspectCalls; $answer = $run($selected, [['body' => $json($response($query)), 'http' => 302]], $httpConfig); $expect(!$answer['ok'] && $inspectCalls === $before + 1 && $inspectScenarios === [], 'HTTP redirect failure has no HTTPS fallback or retry'); $before = $inspectCalls; $answer = $run($selected, [['body' => $json($response($query)), 'errno' => 7]], $httpConfig); $expect(!$answer['ok'] && $inspectCalls === $before + 1 && $inspectScenarios === [], 'HTTP transport failure has no alternate-scheme fallback'); $before = $inspectCalls; $answer = $run($selected, [['body' => $json($response($query)), 'errno' => 60]], $config); $httpsOptions = $inspectOptions[array_key_last($inspectOptions)]; $expect(!$answer['ok'] && $inspectCalls === $before + 1 && $inspectScenarios === [] && $httpsOptions[CURLOPT_PROTOCOLS] === CURLPROTO_HTTPS && $httpsOptions[CURLOPT_SSL_VERIFYPEER] === true && $httpsOptions[CURLOPT_SSL_VERIFYHOST] === 2, 'HTTPS certificate failure stays verified and cannot downgrade to HTTP'); foreach (['ftp://dify.synthetic.invalid/v1', 'https://user:secret@dify.synthetic.invalid/v1', 'https://dify.synthetic.invalid/v1?private=secret', 'https://dify.synthetic.invalid/v1/chat/completions'] as $url) { $changed = $config; $changed['base_url'] = $url; $before = $inspectCalls; $answer = $run($selected, [], $changed); $expect(!$answer['ok'] && $inspectCalls === $before, 'invalid or unsupported-scheme root does not fetch'); } $before = $inspectCalls; $answer = RunInputInspector::inspect($root, $selected, static function (): never { throw new RuntimeException('private-config-secret'); }); $expect(!$answer['ok'] && $inspectCalls === $before && !str_contains(json_encode($answer), 'private-config-secret'), 'configuration failure has no GET or raw exception disclosure'); $probe = file_get_contents(dirname(__DIR__, 2) . '/app/artifacts/ai_concurrency/private_tcm_r25_run_input_probe.php'); $expect(strpos($probe, "PHP_SAPI !== 'cgi-fcgi'") < strpos($probe, 'new \\think\\App'), 'private probe refuses non-FastCGI before bootstrap'); $expect(str_contains($probe, "\$_SERVER['REMOTE_ADDR']") && str_contains($probe, "'127.0.0.1'") && str_contains($probe, "\$_SERVER['REQUEST_METHOD']"), 'loopback POST boundary remains explicit'); foreach (['InitMiddleware', 'LoginMiddleware', 'AuthMiddleware', 'DoctorWebConsultationMiddleware'] as $middleware) $expect(str_contains($probe, $middleware . '::class'), 'canonical middleware present: ' . $middleware); $expect(strpos($probe, "getMethod('loadAuthorizedDiagnosis')") < strpos($probe, 'RunInputInspector::inspect') && str_contains($probe, "'tcm.diagnosis/aiassistant'") && str_contains($probe, 'invoke(null, 11,'), 'canonical case11 permission check precedes input inspection'); $expect(!preg_match('/AssistantRequestControl|streamChat\(|streamPreparedAssistant\(|stopBoundTask\(|reconcileClosedTasks\(/', $probe), 'inspection probe has no control, inference, stop or reconciliation action'); $expect(str_contains($probe, "['token', 'id', 'synthetic', 'profile', 'test_label', 'result_file', 'audit_sha256']"), 'closed POST selector excludes URL/path/run/UUID/query/config overrides'); $expect(str_contains($probe, 'RunInputInspector::inspectResultRow('), 'live probe cannot bypass actual saved-row digest binding'); for ($i = 0; $i < 129; ++$i) file_put_contents($directory . '/bounded-scan-' . $i, ''); $before = $inspectCalls; $answer = $run($selector, []); $expect(!$answer['ok'] && $answer['status'] === 'audit_scan_limit' && $inspectCalls === $before, 'bounded directory scan cannot guess an entry beyond its complete scan limit'); $expect($inspectCalls === $inspectClosed, 'every created fake handle closed'); $expect($inspectScenarios === [], 'only declared stub GETs consumed'); echo "Private exact run input inspector: {$checks} checks passed (synthetic private files + namespaced cURL/time; no auth/network/bootstrap/inference).\n"; } finally { $real = realpath($base); $temp = realpath(sys_get_temp_dir()); if ($real === false || $temp === false || !str_starts_with(str_replace('\\', '/', $real), rtrim(str_replace('\\', '/', $temp), '/') . '/tcm-r25-input-inspector-test-')) throw new RuntimeException('Cleanup boundary'); $entries = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($real, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST); foreach ($entries as $entry) { if ($entry->isLink()) throw new RuntimeException('Cleanup symlink'); if ($entry->isDir()) rmdir($entry->getPathname()); else unlink($entry->getPathname()); } rmdir($real); } }