379 lines
34 KiB
PHP
379 lines
34 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace PrivateTcmR25b {
|
|
function hrtime(bool $number = false): int { return $GLOBALS['inspectClock']; }
|
|
function curl_init(): object {
|
|
++$GLOBALS['inspectCalls'];
|
|
$scenario = array_shift($GLOBALS['inspectScenarios']);
|
|
if (!is_array($scenario)) throw new \RuntimeException('Unexpected test GET');
|
|
return (object) ['scenario' => $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_r25b_run_input_inspector.php';
|
|
use PrivateTcmR25b\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-r25b-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<REPORT_CONTINUATION_DATA>\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', 'serialized_inputs'], 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);
|
|
}
|
|
|
|
// New documented serialized-input compatibility. These are stubbed
|
|
// responses; this test does not establish the deployed response type.
|
|
[$serializedSelector] = $make([$query]);
|
|
foreach (['serialized_sys', 'serialized_query', 'serialized_same_both', 'serialized_mismatch',
|
|
'serialized_conflict', 'serialized_invalid_query', 'serialized_nested', 'serialized_output_only',
|
|
'malformed', 'invalid_utf8', 'empty_string', 'decoded_array', 'decoded_null', 'decoded_number',
|
|
'decoded_boolean', 'recursive_inputs', 'recursive_query', 'inner_depth', 'missing', 'null',
|
|
'object_empty', 'object_array'] as $kind) {
|
|
$value = $response($query); $value['status'] = 'running';
|
|
$value['inputs'] = $json(['sys.query' => $query]);
|
|
$wantedStatus = 'query_match'; $encoding = 'json_string'; $availability = 'available';
|
|
if ($kind === 'serialized_query') $value['inputs'] = $json(['query' => $query]);
|
|
if ($kind === 'serialized_same_both') $value['inputs'] = $json(['sys.query' => $query, 'query' => $query]);
|
|
if ($kind === 'serialized_mismatch') { $value['inputs'] = $json(['query' => $query . "\n"]); $wantedStatus = 'query_mismatch'; }
|
|
if ($kind === 'serialized_conflict') { $value['inputs'] = $json(['sys.query' => $query, 'query' => 'different']); $wantedStatus = 'query_input_ambiguous'; }
|
|
if ($kind === 'serialized_invalid_query') { $value['inputs'] = $json(['sys.query' => $query, 'query' => null]); $wantedStatus = 'query_input_invalid'; }
|
|
if ($kind === 'serialized_nested') { $value['inputs'] = $json(['nested' => ['query' => $query]]); $wantedStatus = 'query_input_unavailable'; }
|
|
if ($kind === 'serialized_output_only') { $value['inputs'] = '{}'; $value['outputs']['query'] = $query; $wantedStatus = 'query_input_unavailable'; }
|
|
if ($kind === 'recursive_query') { $value['inputs'] = $json(['query' => json_encode($query, JSON_THROW_ON_ERROR)]); $wantedStatus = 'query_mismatch'; }
|
|
if (in_array($kind, ['malformed', 'empty_string', 'decoded_array', 'decoded_null', 'decoded_number', 'decoded_boolean', 'recursive_inputs', 'inner_depth'], true)) {
|
|
$value['inputs'] = match ($kind) {
|
|
'malformed' => '{private-malformed', 'empty_string' => '', 'decoded_array' => '[]', 'decoded_null' => 'null',
|
|
'decoded_number' => '123', 'decoded_boolean' => 'false',
|
|
'recursive_inputs' => json_encode($json(['query' => $query]), JSON_THROW_ON_ERROR),
|
|
'inner_depth' => str_repeat('{"nested":', 65) . '0' . str_repeat('}', 65),
|
|
};
|
|
$wantedStatus = 'query_input_invalid'; $availability = 'invalid';
|
|
}
|
|
if ($kind === 'missing') { unset($value['inputs']); $encoding = 'missing'; $availability = 'missing'; $wantedStatus = 'query_input_unavailable'; }
|
|
if ($kind === 'null' || $kind === 'object_array') { $value['inputs'] = $kind === 'null' ? null : []; $encoding = 'unsupported'; $availability = 'invalid'; $wantedStatus = 'query_input_invalid'; }
|
|
if ($kind === 'object_empty') { $value['inputs'] = new stdClass(); $encoding = 'object'; $wantedStatus = 'query_input_unavailable'; }
|
|
$body = $json($value);
|
|
if ($kind === 'invalid_utf8') {
|
|
// A valid outer JSON string representing malformed UTF-8 JSON
|
|
// cannot be emitted by standard json_encode. Use a valid inner
|
|
// JSON escape for an unpaired surrogate, rejected on inner decode.
|
|
$value['inputs'] = '{"query":"\uD800"}'; $body = $json($value);
|
|
$wantedStatus = 'query_input_invalid'; $availability = 'invalid';
|
|
}
|
|
$answer = $run($serializedSelector, [['body' => $body]]); $row = $answer['runs'][0];
|
|
$expect($row['status'] === $wantedStatus && $answer['ok'] === ($wantedStatus === 'query_match'), 'serialized input status: ' . $kind);
|
|
$expect($row['inputs_encoding'] === $encoding && $row['inputs_availability'] === $availability, 'safe encoding/availability enums: ' . $kind);
|
|
$expect($row['returned_run_id_matches'] === true && $row['run_status'] === 'running', 'original id match/status independent of query: ' . $kind);
|
|
$safe = json_encode($answer, JSON_THROW_ON_ERROR);
|
|
foreach (['private-run', 'private-task', 'private-qwen-key', 'CASE_DATA', 'must-not-escape', 'private-malformed', 'dify.synthetic'] as $private)
|
|
$expect(!str_contains($safe, $private), 'serialized response raw data never escapes: ' . $kind);
|
|
}
|
|
|
|
$exactQuery = "头部/\\literal\\n\"\r\n尾部 é e\u{0301} 😀\0 \t";
|
|
[$exactSelector] = $make([$exactQuery]);
|
|
foreach (['object', 'json_string'] as $encoding) {
|
|
$value = $response($exactQuery);
|
|
// Default encoding escapes Unicode/slashes; only standard JSON
|
|
// decoding is permitted, with no transformation of the inner text.
|
|
if ($encoding === 'json_string') $value['inputs'] = json_encode(['query' => $exactQuery], JSON_THROW_ON_ERROR);
|
|
$answer = $run($exactSelector, [['body' => json_encode($value, JSON_THROW_ON_ERROR)]]); $row = $answer['runs'][0];
|
|
$expect($answer['ok'] && $row['inputs_encoding'] === $encoding && $row['observed_query_bytes'] === strlen($exactQuery)
|
|
&& $row['observed_query_sha256'] === hash('sha256', $exactQuery), 'Unicode escapes/CRLF/literal escapes compared byte-exactly: ' . $encoding);
|
|
}
|
|
foreach (['crlf_to_lf', 'trim', 'unicode_normalization', 'literal_escape'] as $change) {
|
|
$changedQuery = match ($change) {
|
|
'crlf_to_lf' => str_replace("\r\n", "\n", $exactQuery), 'trim' => trim($exactQuery),
|
|
'unicode_normalization' => str_replace("é", "e\u{0301}", $exactQuery),
|
|
'literal_escape' => str_replace('\\n', "\n", $exactQuery),
|
|
};
|
|
$value = $response($changedQuery); $value['inputs'] = json_encode(['query' => $changedQuery], JSON_THROW_ON_ERROR);
|
|
$answer = $run($exactSelector, [['body' => $json($value)]]);
|
|
$expect(!$answer['ok'] && $answer['runs'][0]['status'] === 'query_mismatch', 'no query normalization: ' . $change);
|
|
}
|
|
foreach (['scheduled', 'running', 'succeeded', 'failed', 'stopped', 'partial-succeeded', 'paused'] as $status) {
|
|
$value = $response($query); $value['status'] = $status; unset($value['inputs']);
|
|
$answer = $run($serializedSelector, [['body' => $json($value)]]); $row = $answer['runs'][0];
|
|
$expect(!$answer['ok'] && $row['returned_run_id_matches'] && $row['run_status'] === $status
|
|
&& $row['inputs_encoding'] === 'missing', 'available original run status never inferred from query availability: ' . $status);
|
|
}
|
|
$value = $response($query); $value['status'] = 'running'; $value['inputs'] = $json(['query' => $query]);
|
|
$answer = $run($serializedSelector, [['body' => $json($value)]]);
|
|
$expect($answer['ok'] && $answer['runs'][0]['run_status'] === 'running'
|
|
&& !array_key_exists('upstream_terminal_confirmed', $answer['runs'][0]), 'matching query does not make a running original run terminal');
|
|
foreach (['wrong_run', 'wrong_task', 'wrong_run_alias', 'bad_status', 'bad_outer_json', 'outer_depth'] as $kind) {
|
|
$value = $response($query); $value['inputs'] = $json(['query' => $query]);
|
|
if ($kind === 'wrong_run') $value['id'] = 'different-run';
|
|
if ($kind === 'wrong_task') $value['task_id'] = 'different-task';
|
|
if ($kind === 'wrong_run_alias') $value['workflow_run_id'] = 'different-run';
|
|
if ($kind === 'bad_status') $value['status'] = 'unrecognized-private-status';
|
|
if ($kind === 'outer_depth') { $nested = 0; for ($i = 0; $i < 65; ++$i) $nested = ['p' => $nested]; $value['padding'] = $nested; }
|
|
$answer = $run($serializedSelector, [['body' => $kind === 'bad_outer_json' ? '{' : $json($value)]]); $row = $answer['runs'][0];
|
|
$expect(!$answer['ok'] && $row['returned_run_id_matches'] === in_array($kind, ['wrong_task', 'wrong_run_alias', 'bad_status'], true), 'independent returned-id boolean: ' . $kind);
|
|
$expect($row['inputs_encoding'] === 'unobserved' && $row['inputs_availability'] === 'unobserved', 'identity/status/outer validation precedes inner input parsing: ' . $kind);
|
|
}
|
|
|
|
$parser = new ReflectionMethod(RunInputInspector::class, 'parseInputs');
|
|
$inner = $json(['query' => $query, 'padding' => '']);
|
|
$inner = $json(['query' => $query, 'padding' => str_repeat('x', 2097152 - strlen($inner))]);
|
|
$expect(strlen($inner) === 2097152, 'exact inner-input byte limit fixture');
|
|
$parsed = $parser->invoke(null, $inner, true);
|
|
$expect($parsed['availability'] === 'available' && $parsed['value'] instanceof stdClass, 'exact 2MiB inner serialized object bound accepted');
|
|
$parsed = $parser->invoke(null, $inner . ' ', true);
|
|
$expect($parsed['availability'] === 'too_large' && $parsed['value'] === null, '2MiB plus one inner byte rejected before decode');
|
|
$parsed = $parser->invoke(null, "{\"query\":\"\xB1\"}", true);
|
|
$expect($parsed['availability'] === 'invalid' && $parsed['value'] === null, 'invalid UTF-8 in serialized JSON rejected without substitution');
|
|
$outer = $response($query); $outer['inputs'] = $inner;
|
|
$answer = $run($serializedSelector, [['body' => $json($outer)]]);
|
|
$expect(!$answer['ok'] && $answer['runs'][0]['status'] === 'response_too_large', 'outer 2MiB cap remains independent and cannot be bypassed by inner encoding');
|
|
unset($inner, $parsed, $outer);
|
|
[$twoSerialized] = $make([$query, $query . '续写']);
|
|
$first = $response($query); $first['inputs'] = $json(['query' => $query]);
|
|
$second = $response($query . '续写', 1); $second['inputs'] = $json(['sys.query' => $query . '续写']);
|
|
$before = $inspectCalls; $answer = $run($twoSerialized, [['body' => $json($first)], ['body' => $json($second)]]);
|
|
$expect($answer['ok'] && $answer['matched_run_count'] === 2 && $inspectCalls === $before + 2, 'two serialized original generations preserve their own exact wire query binding');
|
|
|
|
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_r25b_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 r25b 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-r25b-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);
|
|
}
|
|
}
|