Files
LWLT-AI/tools/browser_order_add_submit_approved.mjs
2026-07-13 19:57:46 +08:00

515 lines
20 KiB
JavaScript

#!/usr/bin/env node
import { createHash } from 'node:crypto';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
const DEFAULT_PORT = process.env.LWLT_CDP_PORT || '9223';
const APPROVAL_TOKEN = 'APPROVE-LTJT-DOINFOJH-SUBMIT';
function parseArgs(argv) {
const args = { _: [] };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (!arg.startsWith('--')) {
args._.push(arg);
continue;
}
const key = arg.slice(2);
const next = argv[i + 1];
if (!next || next.startsWith('--')) args[key] = true;
else {
args[key] = next;
i += 1;
}
}
return args;
}
function usage() {
return [
'Usage:',
' node tools/browser_order_add_submit_approved.mjs --preflight-report reports/real-preflight.json --submit-intercept-report reports/real-intercept.json --execute-live-submit --approval-token APPROVE-LTJT-DOINFOJH-SUBMIT --out reports/live-submit.json',
'',
'Options:',
` --port <number> Chrome DevTools port. Default: ${DEFAULT_PORT}`,
' --preflight-report <path> Required browser_order_add_preflight report with status browser_preflight_passed.',
' --submit-intercept-report <path> Required browser_order_add_submit_intercept report with matching payload_sha256.',
' --execute-live-submit Required. Without this flag the tool refuses to submit.',
` --approval-token <token> Required exact token: ${APPROVAL_TOKEN}`,
' --out <path> Write redacted live submit report.',
' --help Show this help.',
'',
'This is the only tool intended to allow a real DoInfoJH submit, and only after preflight and intercept hashes match.'
].join('\n');
}
function readJson(path) {
return JSON.parse(readFileSync(path, 'utf8'));
}
function writeJson(path, data) {
mkdirSync(dirname(resolve(path)), { recursive: true });
writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`);
}
async function getJson(url) {
const res = await fetch(url);
if (!res.ok) throw new Error(`${res.status} ${res.statusText}: ${url}`);
return res.json();
}
async function getTarget(port) {
const targets = await getJson(`http://127.0.0.1:${port}/json`);
const target = targets.find((item) => item.type === 'page' && item.url.includes('ltjt.yunzhi.run'));
if (!target) throw new Error(`No ltjt.yunzhi.run page target on port ${port}`);
return target;
}
class CDP {
constructor(wsUrl) {
this.wsUrl = wsUrl;
this.id = 0;
this.pending = new Map();
}
connect() {
return new Promise((resolve, reject) => {
this.ws = new WebSocket(this.wsUrl);
this.ws.onopen = resolve;
this.ws.onerror = (event) => reject(new Error(event.message || event.type || 'WebSocket error'));
this.ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (!msg.id || !this.pending.has(msg.id)) return;
const { resolve: ok, reject: fail, timer } = this.pending.get(msg.id);
clearTimeout(timer);
this.pending.delete(msg.id);
msg.error ? fail(new Error(msg.error.message)) : ok(msg.result);
};
});
}
send(method, params = {}, timeoutMs = 30000) {
const id = ++this.id;
this.ws.send(JSON.stringify({ id, method, params }));
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(new Error(`Timeout: ${method}`));
}, timeoutMs);
this.pending.set(id, { resolve, reject, timer });
});
}
close() {
this.ws?.close();
}
}
async function evaluate(cdp, expression, timeoutMs = 30000) {
const result = await cdp.send('Runtime.evaluate', {
expression,
returnByValue: true,
awaitPromise: true
}, timeoutMs);
if (result.exceptionDetails) {
const detail = result.exceptionDetails.exception?.description
|| result.exceptionDetails.exception?.value
|| result.exceptionDetails.text
|| 'Runtime.evaluate failed';
throw new Error(detail);
}
return result.result.value;
}
function validateReports(preflightReport, interceptReport) {
const blockers = [];
if (preflightReport.status !== 'browser_preflight_passed') {
blockers.push(`preflight report status must be browser_preflight_passed, got ${preflightReport.status || '<missing>'}`);
}
if (preflightReport.submit_safety?.live_submit_attempted !== false) {
blockers.push('preflight report must prove live_submit_attempted=false');
}
if (preflightReport.final_form?.serialized_form_redacted !== true) {
blockers.push('preflight report must be redacted by default');
}
const preflightSerializedHash = preflightReport.final_form?.serialized_form_sha256 || '';
const preflightSubmitHash = preflightReport.final_form?.submit_payload_sha256 || '';
const approvedPreflightHash = preflightSubmitHash || preflightSerializedHash;
if (!approvedPreflightHash) {
blockers.push('preflight report is missing final_form.serialized_form_sha256 or final_form.submit_payload_sha256');
}
if (preflightReport.final_form?.required_missing?.length) {
blockers.push('preflight report has required_missing fields');
}
if (interceptReport.status !== 'submit_intercept_captured') {
blockers.push(`submit intercept report status must be submit_intercept_captured, got ${interceptReport.status || '<missing>'}`);
}
if (interceptReport.submit_safety?.DoInfoJH_network_prevented !== true) {
blockers.push('submit intercept report must prove DoInfoJH_network_prevented=true');
}
if (interceptReport.intercepted_submit_count !== 1) {
blockers.push(`submit intercept report must contain exactly one intercepted submit, got ${interceptReport.intercepted_submit_count || 0}`);
}
if (interceptReport.alerts?.length) {
blockers.push('submit intercept report contains validation alerts');
}
const intercepted = interceptReport.intercepted_submits?.[0];
if (!intercepted?.payload_sha256) {
blockers.push('submit intercept report is missing intercepted payload_sha256');
}
if (intercepted?.prevented_from_network !== true) {
blockers.push('submit intercept report must prove intercepted submit was prevented from network');
}
if (intercepted?.payload_redacted !== true) {
blockers.push('submit intercept report must be redacted by default');
}
const interceptHash = intercepted?.payload_sha256 || '';
if (approvedPreflightHash && interceptHash && approvedPreflightHash !== interceptHash) {
blockers.push('preflight approved payload hash does not match submit-intercept payload_sha256');
}
return {
blockers,
expectedPayloadSha256: approvedPreflightHash,
expectedFieldCount: preflightReport.final_form?.serialized_field_count || intercepted?.payload_summary?.field_count || 0
};
}
function submitExpression({ expectedPayloadSha256, expectedFieldCount }) {
return `(async () => {
const expectedPayloadSha256 = ${JSON.stringify(expectedPayloadSha256)};
const expectedFieldCount = ${JSON.stringify(expectedFieldCount || 0)};
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function sha256Sync(ascii) {
function rightRotate(value, amount) {
return (value >>> amount) | (value << (32 - amount));
}
const mathPow = Math.pow;
const maxWord = mathPow(2, 32);
const lengthProperty = 'length';
let i;
let j;
const result = [];
const words = [];
const asciiBitLength = ascii[lengthProperty] * 8;
let hash = sha256Sync.h = sha256Sync.h || [];
let k = sha256Sync.k = sha256Sync.k || [];
let primeCounter = k[lengthProperty];
const isComposite = {};
for (let candidate = 2; primeCounter < 64; candidate += 1) {
if (!isComposite[candidate]) {
for (i = 0; i < 313; i += candidate) isComposite[i] = candidate;
hash[primeCounter] = (mathPow(candidate, 0.5) * maxWord) | 0;
k[primeCounter] = (mathPow(candidate, 1 / 3) * maxWord) | 0;
primeCounter += 1;
}
}
ascii += String.fromCharCode(0x80);
while (ascii[lengthProperty] % 64 - 56) ascii += String.fromCharCode(0);
for (i = 0; i < ascii[lengthProperty]; i += 1) {
j = ascii.charCodeAt(i);
if (j >> 8) throw new Error('sha256Sync only supports 8-bit input');
words[i >> 2] |= j << ((3 - i) % 4) * 8;
}
words[words[lengthProperty]] = ((asciiBitLength / maxWord) | 0);
words[words[lengthProperty]] = (asciiBitLength);
for (j = 0; j < words[lengthProperty];) {
const w = words.slice(j, j += 16);
const oldHash = hash;
hash = hash.slice(0, 8);
for (i = 0; i < 64; i += 1) {
const w15 = w[i - 15];
const w2 = w[i - 2];
const a = hash[0];
const e = hash[4];
const temp1 = hash[7]
+ (rightRotate(e, 6) ^ rightRotate(e, 11) ^ rightRotate(e, 25))
+ ((e & hash[5]) ^ ((~e) & hash[6]))
+ k[i]
+ (w[i] = (i < 16) ? w[i] : (
w[i - 16]
+ (rightRotate(w15, 7) ^ rightRotate(w15, 18) ^ (w15 >>> 3))
+ w[i - 7]
+ (rightRotate(w2, 17) ^ rightRotate(w2, 19) ^ (w2 >>> 10))
) | 0);
const temp2 = (rightRotate(a, 2) ^ rightRotate(a, 13) ^ rightRotate(a, 22))
+ ((a & hash[1]) ^ (a & hash[2]) ^ (hash[1] & hash[2]));
hash = [(temp1 + temp2) | 0].concat(hash);
hash[4] = (hash[4] + temp1) | 0;
}
for (i = 0; i < 8; i += 1) hash[i] = (hash[i] + oldHash[i]) | 0;
}
for (i = 0; i < 8; i += 1) {
for (j = 3; j + 1; j -= 1) {
const b = (hash[i] >> (j * 8)) & 255;
result.push((b < 16 ? '0' : '') + b.toString(16));
}
}
return result.join('');
}
async function sha256(text) {
const buffer = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
return Array.from(new Uint8Array(buffer)).map((byte) => byte.toString(16).padStart(2, '0')).join('');
}
function findOrderWindow() {
const queue = [window];
while (queue.length) {
const win = queue.shift();
let doc;
try { doc = win.document; } catch (err) { continue; }
if (doc.querySelector('#ListForm') && doc.location.href.includes('/orders_add.asp')) return win;
Array.from(win.frames).forEach((frame) => queue.push(frame));
}
return null;
}
const orderWin = findOrderWindow();
if (!orderWin) return { ok: false, status: 'live_submit_blocked', blockers: ['orders_add_form_not_found'] };
const doc = orderWin.document;
const form = doc.querySelector('#ListForm');
if (!form || form.elements.length < 800) {
return { ok: false, status: 'live_submit_blocked', blockers: ['orders_add_form_not_ready'], form_element_count: form?.elements?.length || 0 };
}
const $ = orderWin.jQuery || orderWin.$;
if (!$ || typeof $.ajax !== 'function') {
return { ok: false, status: 'live_submit_blocked', blockers: ['jquery_ajax_not_found'] };
}
if (typeof orderWin.SubmitInfoForm !== 'function') {
return { ok: false, status: 'live_submit_blocked', blockers: ['SubmitInfoForm_not_found'] };
}
const serializedForm = orderWin.jQuery
? orderWin.jQuery(form).serialize()
: Array.from(new FormData(form).entries()).map(([key, value]) => encodeURIComponent(key) + '=' + encodeURIComponent(value)).join('&');
const requestBody = 'Act=DoInfoJH&' + serializedForm;
const currentHash = await sha256(requestBody);
const params = new URLSearchParams(serializedForm);
const blockers = [];
if (expectedFieldCount && Array.from(params.keys()).length !== expectedFieldCount) blockers.push('current browser field count does not match approved report field count');
if (blockers.length) {
return {
ok: true,
status: 'live_submit_blocked',
current_payload: {
payload_sha256: currentHash,
field_count: Array.from(params.keys()).length,
value_redacted: true
},
blockers,
submit_safety: {
live_submit_attempted: false
}
};
}
const originalAjax = $.ajax;
const ajaxRecords = [];
const alerts = [];
const originalAlert = orderWin.alert;
const originalDialogAlert = $.dialog?.alert;
orderWin.alert = function liveSubmitAlert(message) {
alerts.push({ type: 'alert', message_length: String(message || '').length, message_redacted: true });
return originalAlert ? originalAlert.apply(this, arguments) : undefined;
};
if ($.dialog && typeof $.dialog.alert === 'function') {
$.dialog.alert = function liveSubmitDialogAlert(message) {
alerts.push({ type: 'dialog.alert', message_length: String(message || '').length, message_redacted: true });
return originalDialogAlert ? originalDialogAlert.apply(this, arguments) : undefined;
};
}
$.ajax = function liveSubmitAjaxWrapper(options, ...rest) {
const config = typeof options === 'string' ? { url: options } : { ...(options || {}) };
const url = String(config.url || '');
const data = String(config.data || '');
const isSubmit = /\\/System\\/DAT\\/orders\\.asp/i.test(url) && data.startsWith('Act=DoInfoJH&');
if (!isSubmit) return originalAjax.call(this, options, ...rest);
const record = {
url_redacted: '/System/DAT/orders.asp',
method: config.type || config.method || 'GET',
dataType: config.dataType || '',
request_payload_sha256: sha256Sync(data),
request_field_count: expectedFieldCount,
live_submit_attempted: true,
prevented_from_network: false,
completed: false,
ok: false,
text_status: '',
response_byte_length: 0,
response_contains_login_timeout: false,
response_contains_permission_text: false,
response_contains_success_hint: false,
response_contains_error_hint: false,
response_redacted: true
};
ajaxRecords.push(record);
if (record.request_payload_sha256 !== expectedPayloadSha256) {
record.prevented_from_network = true;
record.completed = true;
record.ok = false;
record.text_status = 'blocked_hash_mismatch_before_network';
return {
readyState: 4,
status: 0,
statusText: 'blocked_hash_mismatch_before_network',
abort() {}
};
}
const originalSuccess = config.success;
const originalError = config.error;
const originalComplete = config.complete;
config.success = function successWrapper(responseText, textStatus) {
const text = String(responseText || '');
record.ok = true;
record.text_status = textStatus || '';
record.response_byte_length = new Blob([text]).size;
record.response_contains_login_timeout = /登陆|登录|login/i.test(text);
record.response_contains_permission_text = /权限|permission/i.test(text);
record.response_contains_success_hint = /成功|保存|添加|完成|ok|success/i.test(text);
record.response_contains_error_hint = /失败|错误|异常|error|alert\\(/i.test(text) && !record.response_contains_success_hint;
return originalSuccess ? originalSuccess.apply(this, arguments) : undefined;
};
config.error = function errorWrapper(jqXHR, textStatus, errorThrown) {
record.ok = false;
record.text_status = textStatus || '';
record.error_text_redacted = errorThrown ? String(errorThrown).slice(0, 80) : '';
return originalError ? originalError.apply(this, arguments) : undefined;
};
config.complete = function completeWrapper() {
record.completed = true;
return originalComplete ? originalComplete.apply(this, arguments) : undefined;
};
return originalAjax.call(this, config, ...rest);
};
let thrown = '';
try {
orderWin.SubmitInfoForm();
} catch (err) {
thrown = err.message || String(err);
}
const started = Date.now();
while (Date.now() - started < 30000) {
if (ajaxRecords.some((record) => record.completed)) break;
await sleep(250);
}
$.ajax = originalAjax;
orderWin.alert = originalAlert;
if ($.dialog && typeof originalDialogAlert === 'function') $.dialog.alert = originalDialogAlert;
const submitBlockers = [];
if (thrown) submitBlockers.push('SubmitInfoForm threw before ajax');
if (!ajaxRecords.length) submitBlockers.push('SubmitInfoForm did not attempt DoInfoJH ajax submit');
if (ajaxRecords.length !== 1) submitBlockers.push('Expected exactly one DoInfoJH ajax submit attempt');
if (ajaxRecords[0]?.prevented_from_network) submitBlockers.push('DoInfoJH ajax was blocked before network because payload hash mismatched');
if (ajaxRecords[0] && !ajaxRecords[0].completed) submitBlockers.push('DoInfoJH ajax did not complete before timeout');
if (ajaxRecords[0]?.response_contains_login_timeout) submitBlockers.push('DoInfoJH response looks like login timeout');
if (ajaxRecords[0]?.response_contains_permission_text) submitBlockers.push('DoInfoJH response contains permission text');
return {
ok: true,
status: submitBlockers.length ? 'live_submit_uncertain_or_failed' : 'live_submit_completed',
page: { url: doc.location.href, title: doc.title, form_element_count: form.elements.length },
current_payload: {
payload_sha256: currentHash,
field_count: Array.from(params.keys()).length,
value_redacted: true
},
ajax_records: ajaxRecords,
alerts,
blockers: submitBlockers,
submit_safety: {
live_submit_attempted: ajaxRecords.some((record) => record.live_submit_attempted),
approved_payload_sha256_matched: currentHash === expectedPayloadSha256
}
};
})()`;
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
console.log(usage());
return;
}
const localBlockers = [];
if (!args['preflight-report']) localBlockers.push('--preflight-report is required');
if (!args['submit-intercept-report']) localBlockers.push('--submit-intercept-report is required');
if (!args['execute-live-submit']) localBlockers.push('--execute-live-submit is required');
if (args['approval-token'] !== APPROVAL_TOKEN) localBlockers.push(`--approval-token must exactly equal ${APPROVAL_TOKEN}`);
let preflightReport = null;
let interceptReport = null;
if (args['preflight-report']) preflightReport = readJson(args['preflight-report']);
if (args['submit-intercept-report']) interceptReport = readJson(args['submit-intercept-report']);
if (preflightReport && interceptReport) {
localBlockers.push(...validateReports(preflightReport, interceptReport).blockers);
}
if (localBlockers.length) {
const output = {
generated_at: new Date().toISOString(),
status: 'live_submit_refused_before_browser',
blockers: localBlockers,
submit_safety: {
live_submit_attempted: false
}
};
if (args.out) writeJson(args.out, output);
console.log(JSON.stringify({
status: output.status,
blockers: localBlockers.length,
out: args.out || ''
}, null, 2));
process.exit(2);
}
const validated = validateReports(preflightReport, interceptReport);
const target = await getTarget(args.port || DEFAULT_PORT);
const cdp = new CDP(target.webSocketDebuggerUrl);
await cdp.connect();
try {
await cdp.send('Runtime.enable');
const output = await evaluate(cdp, submitExpression({
expectedPayloadSha256: validated.expectedPayloadSha256,
expectedFieldCount: validated.expectedFieldCount
}), 60000);
output.generated_at = new Date().toISOString();
output.source_preflight_report = args['preflight-report'];
output.source_submit_intercept_report = args['submit-intercept-report'];
output.approval_gate = {
execute_live_submit_flag: true,
approval_token_matched: true,
preflight_and_intercept_hash_matched: true
};
if (args.out) writeJson(args.out, output);
console.log(JSON.stringify({
status: output.status,
blockers: output.blockers?.length || 0,
live_submit_attempted: output.submit_safety?.live_submit_attempted || false,
ajax_records: output.ajax_records?.length || 0,
out: args.out || ''
}, null, 2));
if (output.status !== 'live_submit_completed') process.exit(2);
} finally {
cdp.close();
}
}
main().catch((error) => {
console.error(error.stack || error.message);
process.exit(1);
});