diff --git a/changelog.d/maintenance/11732-remove-unlicensed-deepseek-wasm.md b/changelog.d/maintenance/11732-remove-unlicensed-deepseek-wasm.md new file mode 100644 index 0000000000..95af43647c --- /dev/null +++ b/changelog.d/maintenance/11732-remove-unlicensed-deepseek-wasm.md @@ -0,0 +1 @@ +- **chore(deepseek-web):** remove the provenance-unresolved DeepSeek PoW WASM binary and its runtime loader/tracing while retaining the existing JavaScript solver (slower at high difficulty) ([#11732](https://github.com/diegosouzapw/OmniRoute/pull/11732)). diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index 86fc92a447..9e7aca2f54 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -317,11 +317,6 @@ "count": 1 } }, - "open-sse/lib/deepseek-pow.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, "open-sse/mcp-server/__tests__/a2aLifecycle.test.ts": { "@typescript-eslint/no-explicit-any": { "count": 1 diff --git a/next.config.mjs b/next.config.mjs index ef67001f34..296a514bb8 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -244,8 +244,8 @@ const nextConfig = { "./src/mitm/server.cjs", "./open-sse/services/compression/engines/rtk/filters/**/*.json", "./open-sse/services/compression/rules/**/*.json", - "./open-sse/lib/sha3_wasm_bg.wasm", - "./open-sse/lib/deepseek-pow-solver.cjs", + "./open-sse/lib/deepseek-pow-hash.js", + "./open-sse/lib/deepseek-pow-worker.mjs", // sql.js WASM is loaded at runtime by the sqljsAdapter fallback tier // (better-sqlite3 → node:sqlite → sql.js). Next traces sql-wasm.js but can // omit the runtime sql-wasm.wasm asset from the standalone bundle. diff --git a/open-sse/executors/deepseek-web.ts b/open-sse/executors/deepseek-web.ts index 2b0fccb489..914024be82 100644 --- a/open-sse/executors/deepseek-web.ts +++ b/open-sse/executors/deepseek-web.ts @@ -141,13 +141,14 @@ function generateFakeCookie(): string { // ── PoW Solver (DeepSeekHashV1) ───────────────────────────────────────── -async function solvePow(challenge: PowChallenge): Promise { +async function solvePow(challenge: PowChallenge, signal?: AbortSignal | null): Promise { const answer = await solveDeepSeekPowAsync( challenge.algorithm, challenge.challenge, challenge.salt, challenge.difficulty, - challenge.expire_at + challenge.expire_at, + { signal } ); if (answer < 0) throw new Error("PoW solver failed"); return Buffer.from( @@ -566,11 +567,7 @@ export function messagesToPrompt( } const effectiveWindow = - historyWindow > 0 - ? historyWindow - : conversation.length > 1 - ? DEFAULT_AUTO_HISTORY_WINDOW - : 0; + historyWindow > 0 ? historyWindow : conversation.length > 1 ? DEFAULT_AUTO_HISTORY_WINDOW : 0; if (effectiveWindow > 0 && conversation.length > 1) { // Rolling-window transcript of the most recent turns (#2942, auto-applied per @@ -932,7 +929,7 @@ export class DeepSeekWebExecutor extends BaseExecutor { // One completion attempt against a given session id (fresh PoW per attempt). const performCompletion = async (sid: string) => { const powChallenge = await getPowChallenge(accessToken, signal); - const powAnswer = await solvePow(powChallenge); + const powAnswer = await solvePow(powChallenge, signal); const reqHeaders: Record = { ...FAKE_HEADERS, "Content-Type": "application/json", @@ -1145,7 +1142,7 @@ export class DeepSeekWebExecutor extends BaseExecutor { const msg = err instanceof Error ? err.message : String(err); log?.error?.("DEEPSEEK-WEB", `Execute failed: ${msg}`); - if (err instanceof DOMException && err.name === "AbortError") { + if (err instanceof Error && err.name === "AbortError") { return { response: errorResponse(499, "Request cancelled"), url: COMPLETION_URL, diff --git a/open-sse/lib/deepseek-pow-hash.js b/open-sse/lib/deepseek-pow-hash.js new file mode 100644 index 0000000000..10941df9b4 --- /dev/null +++ b/open-sse/lib/deepseek-pow-hash.js @@ -0,0 +1,450 @@ +// Clean-room reference derived from NIST FIPS 202, Algorithms 1-9. FIPS SHA3-256 +// uses KECCAK-p[1600,24]. Differential testing against the supplied black-box +// vectors identifies DeepSeekHashV1 as the same sponge construction with +// KECCAK-p[1600,23] (the last 23 rounds, with round indices 1 through 23). + +const LANE_MASK = (1n << 64n) - 1n; +const SHA3_256_RATE_BYTES = 136; +const SHA3_DOMAIN_SUFFIX = 0x06; +const SHA3_256_OUTPUT_BYTES = 32; + +// Indexed as x + 5*y, matching the FIPS 202 state coordinates. +const ROTATION_OFFSETS = [ + 0, 1, 62, 28, 27, 36, 44, 6, 55, 20, 3, 10, 43, 25, 39, 41, 45, 15, 21, 8, 18, 2, 61, 56, 14, +]; + +const ROUND_CONSTANTS = [ + 0x0000000000000001n, + 0x0000000000008082n, + 0x800000000000808an, + 0x8000000080008000n, + 0x000000000000808bn, + 0x0000000080000001n, + 0x8000000080008081n, + 0x8000000000008009n, + 0x000000000000008an, + 0x0000000000000088n, + 0x0000000080008009n, + 0x000000008000000an, + 0x000000008000808bn, + 0x800000000000008bn, + 0x8000000000008089n, + 0x8000000000008003n, + 0x8000000000008002n, + 0x8000000000000080n, + 0x000000000000800an, + 0x800000008000000an, + 0x8000000080008081n, + 0x8000000000008080n, + 0x0000000080000001n, + 0x8000000080008008n, +]; + +/** + * @param {bigint} value + * @param {number} amount + * @returns {bigint} + */ +function rotateLeft64(value, amount) { + if (amount === 0) return value; + const shift = BigInt(amount); + return ((value << shift) | (value >> (64n - shift))) & LANE_MASK; +} + +/** + * Apply the last `roundCount` rounds of KECCAK-p[1600, roundCount]. + * + * @param {bigint[]} state + * @param {number} roundCount + */ +function keccakP1600Reference(state, roundCount) { + /** @type {bigint[]} */ + const columnParity = new Array(5).fill(0n); + /** @type {bigint[]} */ + const thetaMix = new Array(5).fill(0n); + /** @type {bigint[]} */ + const rhoPiState = new Array(25).fill(0n); + const firstRound = ROUND_CONSTANTS.length - roundCount; + + for (let round = firstRound; round < ROUND_CONSTANTS.length; round++) { + for (let x = 0; x < 5; x++) { + columnParity[x] = state[x] ^ state[x + 5] ^ state[x + 10] ^ state[x + 15] ^ state[x + 20]; + } + for (let x = 0; x < 5; x++) { + thetaMix[x] = columnParity[(x + 4) % 5] ^ rotateLeft64(columnParity[(x + 1) % 5], 1); + } + for (let y = 0; y < 5; y++) { + for (let x = 0; x < 5; x++) state[x + 5 * y] ^= thetaMix[x]; + } + + for (let y = 0; y < 5; y++) { + for (let x = 0; x < 5; x++) { + const destinationX = y; + const destinationY = (2 * x + 3 * y) % 5; + const lane = x + 5 * y; + rhoPiState[destinationX + 5 * destinationY] = rotateLeft64( + state[lane], + ROTATION_OFFSETS[lane] + ); + } + } + + for (let y = 0; y < 5; y++) { + const row = 5 * y; + for (let x = 0; x < 5; x++) { + state[x + row] = + rhoPiState[x + row] ^ + (~rhoPiState[((x + 1) % 5) + row] & LANE_MASK & rhoPiState[((x + 2) % 5) + row]); + } + } + state[0] ^= ROUND_CONSTANTS[round]; + } +} + +/** + * @param {bigint[]} state + * @param {Uint8Array} block + * @param {number} roundCount + */ +function absorbReferenceBlock(state, block, roundCount) { + for (let index = 0; index < SHA3_256_RATE_BYTES; index++) { + const lane = Math.floor(index / 8); + const shift = BigInt((index % 8) * 8); + state[lane] ^= BigInt(block[index]) << shift; + } + keccakP1600Reference(state, roundCount); +} + +/** + * SHA3-256's sponge parameters with a selectable KECCAK-p round count. + * + * @param {string} input + * @param {number} roundCount + * @returns {string} + */ +function sha3_256ReferenceWithRoundCount(input, roundCount) { + const bytes = new TextEncoder().encode(input); + /** @type {bigint[]} */ + const state = new Array(25).fill(0n); + let offset = 0; + + while (offset + SHA3_256_RATE_BYTES <= bytes.length) { + absorbReferenceBlock(state, bytes.subarray(offset, offset + SHA3_256_RATE_BYTES), roundCount); + offset += SHA3_256_RATE_BYTES; + } + + const finalBlock = new Uint8Array(SHA3_256_RATE_BYTES); + finalBlock.set(bytes.subarray(offset)); + finalBlock[bytes.length - offset] ^= SHA3_DOMAIN_SUFFIX; + finalBlock[SHA3_256_RATE_BYTES - 1] ^= 0x80; + absorbReferenceBlock(state, finalBlock, roundCount); + + const output = new Uint8Array(SHA3_256_OUTPUT_BYTES); + for (let index = 0; index < output.length; index++) { + const lane = Math.floor(index / 8); + const shift = BigInt((index % 8) * 8); + output[index] = Number((state[lane] >> shift) & 0xffn); + } + return Array.from(output, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +/** + * Readable FIPS 202 control implementation used to validate the permutation, + * byte order, padding, and multi-block absorption against `node:crypto`. + * + * @param {string} input + * @returns {string} + */ +export function sha3_256Fips202Reference(input) { + return sha3_256ReferenceWithRoundCount(input, 24); +} + +/** + * Readable DeepSeekHashV1 reference model. Runtime searches use an equivalent + * 32-bit implementation below so a bounded synchronous compatibility call does + * not turn a 144k challenge into minutes of BigInt work. + * + * @param {string} input + * @returns {string} + */ +export function deepSeekHashV1Reference(input) { + return sha3_256ReferenceWithRoundCount(input, 23); +} + +const DEEPSEEK_HASH_ROUNDS = 23; +const DIGEST_HEX_PATTERN = /^[a-f0-9]{64}$/i; +const ROUND_CONSTANTS_LOW = Uint32Array.from(ROUND_CONSTANTS, (value) => + Number(value & 0xffffffffn) +); +const ROUND_CONSTANTS_HIGH = Uint32Array.from(ROUND_CONSTANTS, (value) => + Number((value >> 32n) & 0xffffffffn) +); +const RHO_PI_DESTINATION_WORDS = Uint8Array.from({ length: 25 }, (_, lane) => { + const x = lane % 5; + const y = Math.floor(lane / 5); + return 2 * (y + 5 * ((2 * x + 3 * y) % 5)); +}); +const CHI_NEXT_WORDS = Uint8Array.from({ length: 25 }, (_, lane) => { + const x = lane % 5; + const row = lane - x; + return 2 * (row + ((x + 1) % 5)); +}); +const CHI_NEXT_NEXT_WORDS = Uint8Array.from({ length: 25 }, (_, lane) => { + const x = lane % 5; + const row = lane - x; + return 2 * (row + ((x + 2) % 5)); +}); +const HEX_DIGITS = "0123456789abcdef"; + +export const MAX_DEEPSEEK_POW_DIFFICULTY = 250_000; + +/** + * Equivalent 32-bit form of the reference permutation. Each 64-bit lane is + * stored as adjacent little-endian low/high uint32 words. + * + * @param {Uint32Array} state + * @param {Uint32Array} rhoPiState + * @param {Uint32Array} columnParity + * @param {Uint32Array} thetaMix + * @param {number} roundCount + */ +function keccakP1600Uint32(state, rhoPiState, columnParity, thetaMix, roundCount) { + const firstRound = ROUND_CONSTANTS.length - roundCount; + + for (let round = firstRound; round < ROUND_CONSTANTS.length; round++) { + for (let x = 0; x < 5; x++) { + const word = 2 * x; + columnParity[word] = + state[word] ^ state[word + 10] ^ state[word + 20] ^ state[word + 30] ^ state[word + 40]; + columnParity[word + 1] = + state[word + 1] ^ state[word + 11] ^ state[word + 21] ^ state[word + 31] ^ state[word + 41]; + } + + for (let x = 0; x < 5; x++) { + const previous = 2 * ((x + 4) % 5); + const next = 2 * ((x + 1) % 5); + const rotatedLow = (columnParity[next] << 1) | (columnParity[next + 1] >>> 31); + const rotatedHigh = (columnParity[next + 1] << 1) | (columnParity[next] >>> 31); + thetaMix[2 * x] = columnParity[previous] ^ rotatedLow; + thetaMix[2 * x + 1] = columnParity[previous + 1] ^ rotatedHigh; + } + + for (let x = 0; x < 5; x++) { + const word = 2 * x; + const low = thetaMix[word]; + const high = thetaMix[word + 1]; + state[word] ^= low; + state[word + 1] ^= high; + state[word + 10] ^= low; + state[word + 11] ^= high; + state[word + 20] ^= low; + state[word + 21] ^= high; + state[word + 30] ^= low; + state[word + 31] ^= high; + state[word + 40] ^= low; + state[word + 41] ^= high; + } + + rhoPiState[0] = state[0]; + rhoPiState[1] = state[1]; + for (let lane = 1; lane < 25; lane++) { + const source = 2 * lane; + const destination = RHO_PI_DESTINATION_WORDS[lane]; + const amount = ROTATION_OFFSETS[lane]; + const low = state[source]; + const high = state[source + 1]; + + if (amount < 32) { + rhoPiState[destination] = (low << amount) | (high >>> (32 - amount)); + rhoPiState[destination + 1] = (high << amount) | (low >>> (32 - amount)); + } else { + const reduced = amount - 32; + rhoPiState[destination] = (high << reduced) | (low >>> (32 - reduced)); + rhoPiState[destination + 1] = (low << reduced) | (high >>> (32 - reduced)); + } + } + + for (let lane = 0; lane < 25; lane++) { + const word = 2 * lane; + const nextWord = CHI_NEXT_WORDS[lane]; + const nextNextWord = CHI_NEXT_NEXT_WORDS[lane]; + state[word] = rhoPiState[word] ^ (~rhoPiState[nextWord] & rhoPiState[nextNextWord]); + state[word + 1] = + rhoPiState[word + 1] ^ (~rhoPiState[nextWord + 1] & rhoPiState[nextNextWord + 1]); + } + + state[0] ^= ROUND_CONSTANTS_LOW[round]; + state[1] ^= ROUND_CONSTANTS_HIGH[round]; + } +} + +/** + * @param {Uint32Array} state + * @param {Uint8Array} bytes + * @param {number} offset + * @param {Uint32Array} rhoPiState + * @param {Uint32Array} columnParity + * @param {Uint32Array} thetaMix + * @param {number} roundCount + */ +function absorbFullUint32Block( + state, + bytes, + offset, + rhoPiState, + columnParity, + thetaMix, + roundCount +) { + for (let index = 0; index < SHA3_256_RATE_BYTES; index++) { + const word = index >>> 2; + state[word] ^= bytes[offset + index] << ((index & 3) * 8); + } + keccakP1600Uint32(state, rhoPiState, columnParity, thetaMix, roundCount); +} + +/** + * @param {Uint32Array} state + * @returns {string} + */ +function digestStateToHex(state) { + let digest = ""; + for (let index = 0; index < SHA3_256_OUTPUT_BYTES; index++) { + const byte = (state[index >>> 2] >>> ((index & 3) * 8)) & 0xff; + digest += HEX_DIGITS[byte >>> 4] + HEX_DIGITS[byte & 0x0f]; + } + return digest; +} + +/** + * @param {string} input + * @param {number} roundCount + * @returns {string} + */ +function sha3_256Uint32WithRoundCount(input, roundCount) { + const bytes = new TextEncoder().encode(input); + const state = new Uint32Array(50); + const rhoPiState = new Uint32Array(50); + const columnParity = new Uint32Array(10); + const thetaMix = new Uint32Array(10); + let offset = 0; + + while (offset + SHA3_256_RATE_BYTES <= bytes.length) { + absorbFullUint32Block(state, bytes, offset, rhoPiState, columnParity, thetaMix, roundCount); + offset += SHA3_256_RATE_BYTES; + } + + const remaining = bytes.length - offset; + for (let index = 0; index < remaining; index++) { + state[index >>> 2] ^= bytes[offset + index] << ((index & 3) * 8); + } + state[remaining >>> 2] ^= SHA3_DOMAIN_SUFFIX << ((remaining & 3) * 8); + state[(SHA3_256_RATE_BYTES - 1) >>> 2] ^= 0x80 << 24; + keccakP1600Uint32(state, rhoPiState, columnParity, thetaMix, roundCount); + return digestStateToHex(state); +} + +/** + * Optimized, allocation-bounded DeepSeekHashV1 digest. + * + * @param {string} input + * @returns {string} + */ +export function deepSeekHashV1(input) { + return sha3_256Uint32WithRoundCount(input, DEEPSEEK_HASH_ROUNDS); +} + +/** + * @param {string} digestHex + * @returns {Uint32Array} + */ +function parseDigestWords(digestHex) { + const words = new Uint32Array(SHA3_256_OUTPUT_BYTES / 4); + for (let index = 0; index < SHA3_256_OUTPUT_BYTES; index++) { + const byte = Number.parseInt(digestHex.slice(index * 2, index * 2 + 2), 16); + words[index >>> 2] |= byte << ((index & 3) * 8); + } + return words; +} + +/** + * Search `prefix + nonce` without allocating a digest or re-encoding the prefix + * for every candidate. Inputs are validated here as well as at the public solver + * boundary so the worker cannot be coerced into an unbounded loop. + * + * @param {string} prefix + * @param {string} challenge + * @param {number} difficulty + * @returns {number} + */ +export function findDeepSeekPowNonce(prefix, challenge, difficulty) { + if (typeof prefix !== "string") throw new TypeError("DeepSeek PoW prefix must be a string"); + if (!DIGEST_HEX_PATTERN.test(challenge)) { + throw new TypeError("DeepSeek PoW challenge must be a 64-character hex digest"); + } + if ( + !Number.isSafeInteger(difficulty) || + difficulty < 1 || + difficulty > MAX_DEEPSEEK_POW_DIFFICULTY + ) { + throw new RangeError( + `DeepSeek PoW difficulty must be an integer from 1 to ${MAX_DEEPSEEK_POW_DIFFICULTY}` + ); + } + + const prefixBytes = new TextEncoder().encode(prefix); + const baseState = new Uint32Array(50); + const rhoPiState = new Uint32Array(50); + const columnParity = new Uint32Array(10); + const thetaMix = new Uint32Array(10); + let prefixOffset = 0; + + while (prefixOffset + SHA3_256_RATE_BYTES <= prefixBytes.length) { + absorbFullUint32Block( + baseState, + prefixBytes, + prefixOffset, + rhoPiState, + columnParity, + thetaMix, + DEEPSEEK_HASH_ROUNDS + ); + prefixOffset += SHA3_256_RATE_BYTES; + } + + const tailLength = prefixBytes.length - prefixOffset; + const tailWords = new Uint32Array(Math.ceil(tailLength / 4)); + for (let index = 0; index < tailLength; index++) { + tailWords[index >>> 2] ^= prefixBytes[prefixOffset + index] << ((index & 3) * 8); + } + + const targetWords = parseDigestWords(challenge.toLowerCase()); + const state = new Uint32Array(50); + + nonceLoop: for (let nonce = 0; nonce < difficulty; nonce++) { + state.set(baseState); + for (let word = 0; word < tailWords.length; word++) state[word] ^= tailWords[word]; + + let position = tailLength; + const nonceText = String(nonce); + for (let index = 0; index < nonceText.length; index++) { + state[position >>> 2] ^= nonceText.charCodeAt(index) << ((position & 3) * 8); + position += 1; + if (position === SHA3_256_RATE_BYTES) { + keccakP1600Uint32(state, rhoPiState, columnParity, thetaMix, DEEPSEEK_HASH_ROUNDS); + position = 0; + } + } + + state[position >>> 2] ^= SHA3_DOMAIN_SUFFIX << ((position & 3) * 8); + state[(SHA3_256_RATE_BYTES - 1) >>> 2] ^= 0x80 << 24; + keccakP1600Uint32(state, rhoPiState, columnParity, thetaMix, DEEPSEEK_HASH_ROUNDS); + + for (let word = 0; word < targetWords.length; word++) { + if (state[word] !== targetWords[word]) continue nonceLoop; + } + return nonce; + } + + return -1; +} diff --git a/open-sse/lib/deepseek-pow-solver.cjs b/open-sse/lib/deepseek-pow-solver.cjs deleted file mode 100644 index 4b4ab62722..0000000000 --- a/open-sse/lib/deepseek-pow-solver.cjs +++ /dev/null @@ -1,3 +0,0 @@ -const r=(id)=>{if(id===46743)return{Buffer};return{}};const t={},e={}; -"use strict";let n,i;r(42551),r(40966),r(70968),r(76966),r(35399),r(36279),r(87801),r(16389),r(36073),r(27448),r(10681),r(32014),r(46596),r(39008),r(71),r(85540);var o=r(46743),f=Object.create,u=Object.defineProperty,s=Object.getOwnPropertyDescriptor,a=Object.getOwnPropertyNames,c=Object.getPrototypeOf,h=Object.prototype.hasOwnProperty,l=(t,e,r)=>(r=null!=t?f(c(t)):{},((t,e,r,n)=>{if(e&&"object"==typeof e||"function"==typeof e)for(let i of a(e))h.call(t,i)||i===r||u(t,i,{get:()=>e[i],enumerable:!(n=s(e,i))||n.enumerable});return t})(!e&&t&&t.__esModule?r:u(r,"default",{value:t,enumerable:!0}),t)),p=(n=(t,e)=>{e.exports=(t,e)=>(r,n)=>{let i=2*n,o=2*e;r[i]=t[o],r[i+1]=t[o+1]}},()=>(i||n((i={exports:{}}).exports,i),i.exports)),g=l(p()),y=t=>{let{A:e,C:r}=t;for(let t=0;t<25;t+=5){for(let n=0;n<5;n++)(0,g.default)(e,t+n)(r,n);for(let n=0;n<5;n++){let i=(t+n)*2,o=(n+1)%5*2,f=(n+2)%5*2;e[i]^=~r[o]&r[f],e[i+1]^=~r[o+1]&r[f+1]}}},d=new Uint32Array([0,1,0,32898,0x80000000,32906,0x80000000,0x80008000,0,32907,0,0x80000001,0x80000000,0x80008081,0x80000000,32777,0,138,0,136,0,0x80008009,0,0x8000000a,0,0x8000808b,0x80000000,139,0x80000000,32905,0x80000000,32771,0x80000000,32770,0x80000000,128,0,32778,0x80000000,0x8000000a,0x80000000,0x80008081,0x80000000,32896,0,0x80000001,0x80000000,0x80008008]),b=t=>{let{A:e,I:r}=t,n=2*r;e[0]^=d[n],e[1]^=d[n+1]},v=[10,7,11,17,18,3,5,16,8,21,24,4,15,23,19,13,12,2,20,14,22,9,6,1],w=[1,3,6,10,15,21,28,36,45,55,2,14,27,41,56,8,25,43,62,18,39,61,20,44],x=l(p()),E=t=>{let{A:e,C:r,W:n}=t,i=0;(0,x.default)(e,i+1)(n,i);let o=0,f=0,u=0,s=32;for(;i<24;i++){let t=v[i],a=w[i];(0,x.default)(e,t)(r,0),o=n[0],f=n[1],s=32-a,n[u=a<32?0:1]=o<>>s,n[(u+1)%2]=f<>>s,(0,x.default)(n,0)(e,t),(0,x.default)(r,0)(n,0)}},m=l(p()),B=t=>{let{A:e,C:r,D:n,W:i}=t,o=0,f=0;for(let t=0;t<5;t++){let n=2*t,i=(t+5)*2,o=(t+10)*2,f=(t+15)*2,u=(t+20)*2;r[n]=e[n]^e[i]^e[o]^e[f]^e[u],r[n+1]=e[n+1]^e[i+1]^e[o+1]^e[f+1]^e[u+1]}for(let t=0;t<5;t++){(0,m.default)(r,(t+1)%5)(i,0),o=i[0],f=i[1],i[0]=o<<1|f>>>31,i[1]=f<<1|o>>>31,n[2*t]=r[(t+4)%5*2]^i[0],n[2*t+1]=r[(t+4)%5*2+1]^i[1];for(let r=0;r<25;r+=5)e[(r+t)*2]^=n[2*t],e[(r+t)*2+1]^=n[2*t+1]}},I=(t,e)=>{for(let r=0;r{for(let r=0;r>>8,e[r+2]=t[n+1]>>>16,e[r+3]=t[n+1]>>>24,e[r+4]=t[n],e[r+5]=t[n]>>>8,e[r+6]=t[n]>>>16,e[r+7]=t[n]>>>24}return e},U=function(t){let e,r,n,{capacity:i,padding:f}=t,u=i/8,s=200-i/4,a={keccak:(e=new Uint32Array(10),r=new Uint32Array(10),n=new Uint32Array(2),t=>{for(let i=1;i<24;i++)B({A:t,C:e,D:r,W:n}),E({A:t,C:e,W:n}),y({A:t,C:e}),b({A:t,I:i});e.fill(0),r.fill(0),n.fill(0)}),state:new Uint32Array(50),queue:o.Buffer.allocUnsafe(s),queueOffset:0};return this.getState=()=>a,this.setState=t=>{a.keccak=t.keccak,a.state.set(t.state.slice()),t.queue.copy(a.queue),a.queueOffset=t.queueOffset},this.absorb=t=>{for(let e=0;e=s&&(I(a.queue,a.state),a.keccak(a.state),a.queueOffset=0);return this},this.squeeze=t=>{let e={buffer:o.Buffer.allocUnsafe(u),padding:t,queue:o.Buffer.allocUnsafe(a.queue.length),state:new Uint32Array(a.state.length)};a.queue.copy(e.queue);for(let t=0;t(a.queue.fill(0),a.state.fill(0),a.queueOffset=0,this),this.copy=()=>{let t=new U({capacity:i,padding:f});return t.setState(this.getState()),t},this};/* #2724: guard Web Worker handler — Node.js require() loads this CJS in strict mode where bare `onmessage = ...` throws ReferenceError */typeof self!=="undefined"&&typeof postMessage!=="undefined"&&(globalThis.onmessage=t=>{if("pow-challenge"!==t.data.type)return;let{algorithm:e,challenge:r,salt:n,difficulty:i,signature:f,expireAt:u}=t.data.challenge;try{let t=((t,e,r,n,i)=>{if("DeepSeekHashV1"!==t)throw Error("Unsupported algorithm: "+t);let f="".concat(r,"_").concat(i,"_"),u=function(t,e,r){if(t.length%2!=0)throw RangeError("c.length");if(r<=0||!Number.isSafeInteger(r))throw RangeError("d");for(var n=(function t(){var e=this;return this&&this.constructor===t?(this._sponge=new U({capacity:256}),this.update=t=>{if("string"==typeof t)return this._sponge.absorb(o.Buffer.from(t,"utf8")),this;throw TypeError("input not a string")},this.digest=function(){let t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:"hex";return e._sponge.squeeze(6).toString(t)},this.copy=()=>{let e=new t;return e._sponge=this._sponge.copy(),e},this):new t})(256).update(e),i=0;i MAX_DEEPSEEK_POW_DIFFICULTY +) { + throw new Error("DeepSeek PoW worker received invalid input"); +} + +parentPort.postMessage(findDeepSeekPowNonce(prefix, challenge, difficulty)); diff --git a/open-sse/lib/deepseek-pow.ts b/open-sse/lib/deepseek-pow.ts index 29382c6fb3..ce28d05b35 100644 --- a/open-sse/lib/deepseek-pow.ts +++ b/open-sse/lib/deepseek-pow.ts @@ -1,181 +1,156 @@ -import { createRequire } from "node:module"; -import fs from "node:fs"; +import { existsSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { Worker } from "node:worker_threads"; -// ── WASM solver (fast — ~50-100ms at difficulty 144000) ────────────────── +import { findDeepSeekPowNonce, MAX_DEEPSEEK_POW_DIFFICULTY } from "./deepseek-pow-hash.js"; -class DeepSeekHashWasm { - private wasmInstance: any; - private offset = 0; - private cachedUint8Memory: Uint8Array | null = null; - private cachedTextEncoder = new TextEncoder(); +const DEEPSEEK_POW_ALGORITHM = "DeepSeekHashV1"; +const SHA3_256_HEX_PATTERN = /^[a-f0-9]{64}$/i; +const MAX_DIFFICULTY = MAX_DEEPSEEK_POW_DIFFICULTY; +const MAX_SALT_LENGTH = 1_024; +const MAX_CONCURRENT_WORKERS = 2; +const DEFAULT_TIMEOUT_MS = 10_000; +const MAX_TIMEOUT_MS = 30_000; - private getCachedUint8Memory(): Uint8Array { - if (!this.cachedUint8Memory?.byteLength) { - this.cachedUint8Memory = new Uint8Array(this.wasmInstance.memory.buffer); - } - return this.cachedUint8Memory; - } +let activeWorkerCount = 0; - private encodeString( - text: string, - allocate: (size: number, align: number) => number, - reallocate: (ptr: number, oldSize: number, newSize: number, align: number) => number - ): number { - const strLength = text.length; - let ptr = allocate(strLength, 1) >>> 0; - const memory = this.getCachedUint8Memory(); - let asciiLength = 0; - - for (; asciiLength < strLength; asciiLength++) { - if (text.charCodeAt(asciiLength) > 127) break; - memory[ptr + asciiLength] = text.charCodeAt(asciiLength); - } - - if (asciiLength !== strLength) { - if (asciiLength > 0) text = text.slice(asciiLength); - ptr = reallocate(ptr, strLength, asciiLength + text.length * 3, 1) >>> 0; - const result = this.cachedTextEncoder.encodeInto( - text, - this.getCachedUint8Memory().subarray(ptr + asciiLength, ptr + asciiLength + text.length * 3) - ); - asciiLength += result.written!; - ptr = reallocate(ptr, asciiLength + text.length * 3, asciiLength, 1) >>> 0; - } - - this.offset = asciiLength; - return ptr; - } - - calculateHash(challenge: string, prefix: string, difficulty: number): number | undefined { - try { - const retptr = this.wasmInstance.__wbindgen_add_to_stack_pointer(-16); - - const ptr0 = this.encodeString( - challenge, - this.wasmInstance.__wbindgen_export_0, - this.wasmInstance.__wbindgen_export_1 - ); - const len0 = this.offset; - - const ptr1 = this.encodeString( - prefix, - this.wasmInstance.__wbindgen_export_0, - this.wasmInstance.__wbindgen_export_1 - ); - const len1 = this.offset; - - this.wasmInstance.wasm_solve(retptr, ptr0, len0, ptr1, len1, difficulty); - - const dv = new DataView(this.wasmInstance.memory.buffer); - const status = dv.getInt32(retptr + 0, true); - const value = dv.getFloat64(retptr + 8, true); - - return status === 0 ? undefined : value; - } finally { - this.wasmInstance.__wbindgen_add_to_stack_pointer(16); - } - } - - async init(wasmPath: string): Promise { - const wasmBuffer = await fs.promises.readFile(wasmPath); - const { instance } = await WebAssembly.instantiate(wasmBuffer, { wbg: {} }); - this.wasmInstance = instance.exports; - } +export interface SolveDeepSeekPowOptions { + signal?: AbortSignal | null; + timeoutMs?: number; } -let _wasmSolver: DeepSeekHashWasm | null = null; -let _wasmInitFailed = false; - -async function getWasmSolver(): Promise { - if (_wasmInitFailed) return null; - if (_wasmSolver) return _wasmSolver; - - try { - const solver = new DeepSeekHashWasm(); - const wasmPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "sha3_wasm_bg.wasm"); - await solver.init(wasmPath); - _wasmSolver = solver; - return solver; - } catch { - _wasmInitFailed = true; - return null; - } +interface ValidatedChallenge { + challenge: string; + prefix: string; + difficulty: number; } -// ── JS fallback solver (slow — ~5-6s at difficulty 144000) ─────────────── - -const require = createRequire(import.meta.url); -let _U: any | undefined; -function loadU(): any { - if (_U === undefined) { - _U = require("./deepseek-pow-solver.cjs").U; - } - return _U; +function createAbortError(): Error { + const error = new Error("DeepSeek PoW computation aborted"); + error.name = "AbortError"; + return error; } -function solveWithJS(challenge: string, prefix: string, difficulty: number): number { - const U = loadU(); - const createHash = () => { - const self: any = {}; - self._sponge = new U({ capacity: 256, padding: 6 }); - self.update = (s: string) => { - self._sponge.absorb(Buffer.from(s, "utf8")); - return self; - }; - self.digest = (fmt?: string) => { - return self._sponge.squeeze(6).toString(fmt || "hex"); - }; - self.copy = () => { - const c: any = {}; - c._sponge = self._sponge.copy(); - c.update = (s: string) => { - c._sponge.absorb(Buffer.from(s, "utf8")); - return c; - }; - c.digest = (fmt?: string) => { - return c._sponge.squeeze(6).toString(fmt || "hex"); - }; - return c; - }; - return self; +function validateChallenge( + algorithm: string, + challenge: string, + salt: string, + difficulty: number, + expireAt: number +): ValidatedChallenge { + if (algorithm !== DEEPSEEK_POW_ALGORITHM) { + throw new Error(`Unsupported DeepSeek PoW algorithm: ${algorithm}`); + } + if (!SHA3_256_HEX_PATTERN.test(challenge)) { + throw new Error("DeepSeek PoW challenge must be a 64-character SHA3-256 hex digest"); + } + if (typeof salt !== "string" || salt.length === 0 || salt.length > MAX_SALT_LENGTH) { + throw new Error(`DeepSeek PoW salt must contain 1-${MAX_SALT_LENGTH} characters`); + } + if (!Number.isSafeInteger(difficulty) || difficulty < 1 || difficulty > MAX_DIFFICULTY) { + throw new Error(`DeepSeek PoW difficulty must be an integer from 1 to ${MAX_DIFFICULTY}`); + } + if (!Number.isSafeInteger(expireAt) || expireAt < 0) { + throw new Error("DeepSeek PoW expiry must be a non-negative safe integer"); + } + + return { + challenge: challenge.toLowerCase(), + prefix: `${salt}_${expireAt}_`, + difficulty, }; - - const h = createHash(); - h.update(prefix); - - for (let nonce = 0; nonce < difficulty; nonce++) { - if (h.copy().update(String(nonce)).digest("hex") === challenge) { - return nonce; - } - } - return -1; } -// ── Public API ─────────────────────────────────────────────────────────── +function resolveTimeoutMs(timeoutMs: number | undefined): number { + if (timeoutMs === undefined) return DEFAULT_TIMEOUT_MS; + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MAX_TIMEOUT_MS) { + throw new Error(`DeepSeek PoW timeout must be an integer from 1 to ${MAX_TIMEOUT_MS}ms`); + } + return timeoutMs; +} + +function solveSynchronously({ challenge, prefix, difficulty }: ValidatedChallenge): number { + return findDeepSeekPowNonce(prefix, challenge, difficulty); +} + +function resolveWorkerPath(): string { + const tracedPath = path.join(process.cwd(), "open-sse/lib/deepseek-pow-worker.mjs"); + if (existsSync(tracedPath)) return tracedPath; + return fileURLToPath(new URL("./deepseek-pow-worker.mjs", import.meta.url)); +} + +function solveInWorker( + validated: ValidatedChallenge, + options: SolveDeepSeekPowOptions +): Promise { + const { signal } = options; + if (signal?.aborted) return Promise.reject(createAbortError()); + if (activeWorkerCount >= MAX_CONCURRENT_WORKERS) { + return Promise.reject( + new Error(`DeepSeek PoW worker capacity reached (${MAX_CONCURRENT_WORKERS})`) + ); + } + + const timeoutMs = resolveTimeoutMs(options.timeoutMs); + activeWorkerCount += 1; + + return new Promise((resolve, reject) => { + const worker = new Worker(resolveWorkerPath(), { workerData: validated }); + let settled = false; + + const cleanup = () => { + clearTimeout(timeout); + signal?.removeEventListener("abort", onAbort); + activeWorkerCount = Math.max(0, activeWorkerCount - 1); + }; + const finish = (callback: () => void, terminate: boolean) => { + if (settled) return; + settled = true; + cleanup(); + if (terminate) void worker.terminate(); + callback(); + }; + const onAbort = () => { + finish(() => reject(createAbortError()), true); + }; + const timeout = setTimeout(() => { + finish(() => reject(new Error(`DeepSeek PoW computation exceeded ${timeoutMs}ms`)), true); + }, timeoutMs); + + signal?.addEventListener("abort", onAbort, { once: true }); + worker.once("message", (answer: unknown) => { + if (!Number.isSafeInteger(answer) || (answer as number) < -1) { + finish(() => reject(new Error("DeepSeek PoW worker returned an invalid answer")), true); + return; + } + finish(() => resolve(answer as number), false); + }); + worker.once("error", (error) => { + finish(() => reject(error), true); + }); + worker.once("exit", (code) => { + if (code !== 0) { + finish(() => reject(new Error(`DeepSeek PoW worker exited with code ${code}`)), false); + } else { + finish(() => reject(new Error("DeepSeek PoW worker exited without an answer")), false); + } + }); + }); +} export async function solveDeepSeekPowAsync( algorithm: string, challenge: string, salt: string, difficulty: number, - expireAt: number + expireAt: number, + options: SolveDeepSeekPowOptions = {} ): Promise { - if (algorithm !== "DeepSeekHashV1") throw new Error(`Unsupported: ${algorithm}`); - const prefix = `${salt}_${expireAt}_`; - - const wasm = await getWasmSolver(); - if (wasm) { - const answer = wasm.calculateHash(challenge, prefix, difficulty); - if (answer === undefined) return -1; - return answer; - } - - return solveWithJS(challenge, prefix, difficulty); + const validated = validateChallenge(algorithm, challenge, salt, difficulty, expireAt); + return solveInWorker(validated, options); } -// Sync wrapper kept for backward compat (uses JS fallback only) export function solveDeepSeekPow( algorithm: string, challenge: string, @@ -183,7 +158,9 @@ export function solveDeepSeekPow( difficulty: number, expireAt: number ): number { - if (algorithm !== "DeepSeekHashV1") throw new Error(`Unsupported: ${algorithm}`); - const prefix = `${salt}_${expireAt}_`; - return solveWithJS(challenge, prefix, difficulty); + // Compatibility-only synchronous API. The validated 250k ceiling bounds CPU + // use; request handling must use solveDeepSeekPowAsync() so hashing stays off + // the event loop and remains abortable. + const validated = validateChallenge(algorithm, challenge, salt, difficulty, expireAt); + return solveSynchronously(validated); } diff --git a/open-sse/lib/sha3_wasm_bg.wasm b/open-sse/lib/sha3_wasm_bg.wasm deleted file mode 100644 index ac92b1d87e..0000000000 Binary files a/open-sse/lib/sha3_wasm_bg.wasm and /dev/null differ diff --git a/tests/unit/deepseek-pow-js-only.test.ts b/tests/unit/deepseek-pow-js-only.test.ts new file mode 100644 index 0000000000..53660f94ed --- /dev/null +++ b/tests/unit/deepseek-pow-js-only.test.ts @@ -0,0 +1,238 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import test from "node:test"; + +import { solveDeepSeekPow, solveDeepSeekPowAsync } from "../../open-sse/lib/deepseek-pow.ts"; +import { + deepSeekHashV1, + deepSeekHashV1Reference, + sha3_256Fips202Reference, +} from "../../open-sse/lib/deepseek-pow-hash.js"; + +const repoRoot = new URL("../../", import.meta.url); +const noMatchChallenge = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"; +const redistributedArtifacts = [ + new URL("../../open-sse/lib/sha3_wasm_bg.wasm", import.meta.url), + new URL("../../open-sse/lib/deepseek-pow-solver.cjs", import.meta.url), +]; + +const deepSeekHashV1Vectors = [ + [ + "1122334455667788_1778891543095_0", + "311b26ae1e0fe7375e242958ce46db5552a6c67fea3f96880dcd846c63a74286", + ], + [ + "1122334455667788_1778891543095_1", + "526f9103dfe22bcda9481b7f304a157b8edb18c5bb96a2061eefec1fbd0db706", + ], + ["vector_42_0", "1813da791ddac0da3a225e5878ff3d3ea07577e048f8e9575b82a274b71e3810"], + ["vector_42_1", "b582fdb4f83662baec734e23373efc860515c03c40decbef076697eb5832f83c"], + ["vector_42_2", "48b348ad54c372f78ccb6a26cbf156668f4c6a5b7d5a1cf19ccd78888586019b"], + ["vector_42_3", "2ffed26ea9e1d6f4bbe49a266d98fe04ad9a5ad4c6d765862de8d18c513c3815"], + ["bb_1778891543095_0", "705e5d630f02d09a8179c6a0fcb0caf7265f08fb206fadca0301224f4422fc64"], +] as const; + +test("DeepSeekHashV1 is SHA3-256 with the last 23 KECCAK-p[1600] rounds", () => { + for (const [input, expected] of deepSeekHashV1Vectors) { + assert.equal(deepSeekHashV1Reference(input), expected, input); + } +}); + +test("the 24-round FIPS 202 reference agrees with node:crypto on a separate corpus", () => { + const corpus = [ + "", + "abc", + "café", + "🧪", + "a".repeat(135), + "b".repeat(136), + "c".repeat(137), + "multi-block-".repeat(40), + ]; + + for (const input of corpus) { + const expected = createHash("sha3-256").update(input).digest("hex"); + assert.equal(sha3_256Fips202Reference(input), expected, JSON.stringify(input)); + } +}); + +test("the optimized DeepSeekHashV1 implementation agrees with the readable reference", () => { + const corpus = [ + ...deepSeekHashV1Vectors.map(([input]) => input), + "", + "café-🧪", + "a".repeat(135), + "b".repeat(136), + "c".repeat(137), + "multi-block-".repeat(40), + ]; + + for (const input of corpus) { + assert.equal(deepSeekHashV1(input), deepSeekHashV1Reference(input), JSON.stringify(input)); + } +}); + +test("DeepSeek PoW remains functional without redistributing extracted solver artifacts", async () => { + const answer = await solveDeepSeekPowAsync( + "DeepSeekHashV1", + "311b26ae1e0fe7375e242958ce46db5552a6c67fea3f96880dcd846c63a74286", + "1122334455667788", + 1, + 1778891543095 + ); + + assert.equal(answer, 0, "the retained JavaScript solver must satisfy the known PoW vector"); + for (const artifact of redistributedArtifacts) { + assert.equal(existsSync(artifact), false, `${artifact.pathname} must not be redistributed`); + } + assert.equal( + existsSync(new URL("../../open-sse/lib/deepseek-pow-hash.js", import.meta.url)), + true, + "the clean-room JavaScript hash core must be packaged" + ); + assert.equal( + existsSync(new URL("../../open-sse/lib/deepseek-pow-worker.mjs", import.meta.url)), + true, + "the asynchronous worker entry must be packaged" + ); + + for (const relativePath of [ + "open-sse/lib/deepseek-pow-hash.js", + "open-sse/lib/deepseek-pow.ts", + "open-sse/lib/deepseek-pow-worker.mjs", + "next.config.mjs", + "package.json", + "open-sse/package.json", + ]) { + const contents = readFileSync(new URL(relativePath, repoRoot), "utf8"); + assert.doesNotMatch( + contents, + /sha3_wasm_bg\.wasm|deepseek-pow-solver\.cjs/, + `${relativePath} must not reference an extracted solver artifact` + ); + } + + for (const relativePath of [ + "open-sse/lib/deepseek-pow-hash.js", + "open-sse/lib/deepseek-pow.ts", + "open-sse/lib/deepseek-pow-worker.mjs", + ]) { + const contents = readFileSync(new URL(relativePath, repoRoot), "utf8"); + assert.doesNotMatch( + contents, + /createRequire|WebAssembly/, + `${relativePath} must not dynamically load a binary solver` + ); + } + + const nextConfig = readFileSync(new URL("../../next.config.mjs", import.meta.url), "utf8"); + assert.match(nextConfig, /\.\/open-sse\/lib\/deepseek-pow-hash\.js/); + assert.match(nextConfig, /\.\/open-sse\/lib\/deepseek-pow-worker\.mjs/); +}); + +test("DeepSeek PoW rejects malformed or unsafe challenges before hashing", async () => { + await assert.rejects( + solveDeepSeekPowAsync("DeepSeekHashV1", "not-a-sha3-digest", "salt", 1, 1), + /challenge/i + ); + await assert.rejects( + solveDeepSeekPowAsync( + "DeepSeekHashV1", + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "salt", + -1, + 1 + ), + /difficulty/i + ); + await assert.rejects( + solveDeepSeekPowAsync("DeepSeekHashV1", noMatchChallenge, "salt", 250_001, 1), + /difficulty/i + ); + assert.throws( + () => solveDeepSeekPow("DeepSeekHashV1", noMatchChallenge, "salt", 250_001, 1), + /difficulty/i + ); + assert.throws( + () => + solveDeepSeekPow( + "UnknownHash", + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "salt", + 1, + 1 + ), + /unsupported/i + ); +}); + +test("DeepSeek PoW sync and async APIs agree for non-zero and missing answers", async () => { + const nonceThreeChallenge = "2ffed26ea9e1d6f4bbe49a266d98fe04ad9a5ad4c6d765862de8d18c513c3815"; + + assert.equal(solveDeepSeekPow("DeepSeekHashV1", nonceThreeChallenge, "vector", 4, 42), 3); + assert.equal( + await solveDeepSeekPowAsync("DeepSeekHashV1", nonceThreeChallenge, "vector", 4, 42), + 3 + ); + assert.equal(solveDeepSeekPow("DeepSeekHashV1", nonceThreeChallenge, "vector", 3, 42), -1); + assert.equal( + await solveDeepSeekPowAsync("DeepSeekHashV1", nonceThreeChallenge, "vector", 3, 42), + -1 + ); +}); + +test("DeepSeek PoW async search yields to timers and honors cancellation", async () => { + const controller = new AbortController(); + let timerFired = false; + const timer = setTimeout(() => { + timerFired = true; + controller.abort(); + }, 0); + + try { + await assert.rejects( + solveDeepSeekPowAsync( + "DeepSeekHashV1", + noMatchChallenge, + "1122334455667788", + 256, + 1778891543095, + { signal: controller.signal } + ), + (error: unknown) => error instanceof Error && error.name === "AbortError" + ); + assert.equal(timerFired, true, "the event loop must progress while PoW is being searched"); + } finally { + clearTimeout(timer); + } +}); + +test("DeepSeek PoW async search enforces a deterministic timeout", async () => { + await assert.rejects( + solveDeepSeekPowAsync("DeepSeekHashV1", noMatchChallenge, "timeout", 250_000, 1, { + timeoutMs: 1, + }), + /exceeded 1ms/i + ); +}); + +test("DeepSeek PoW caps concurrent worker searches at two", async () => { + const controllers = [new AbortController(), new AbortController()]; + const searches = controllers.map((controller) => + solveDeepSeekPowAsync("DeepSeekHashV1", noMatchChallenge, "capacity", 250_000, 1, { + signal: controller.signal, + timeoutMs: 30_000, + }) + ); + + try { + await assert.rejects( + solveDeepSeekPowAsync("DeepSeekHashV1", noMatchChallenge, "capacity", 1, 1), + /capacity reached \(2\)/i + ); + } finally { + for (const controller of controllers) controller.abort(); + await Promise.allSettled(searches); + } +}); diff --git a/tests/unit/deepseek-pow-solver-strict.test.ts b/tests/unit/deepseek-pow-solver-strict.test.ts deleted file mode 100644 index 81a41b847e..0000000000 --- a/tests/unit/deepseek-pow-solver-strict.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { createRequire } from "node:module"; - -const requireCJS = createRequire(import.meta.url); - -test("deepseek-pow-solver.cjs loads under Node strict mode without ReferenceError (#2724)", () => { - let mod: { U?: unknown }; - assert.doesNotThrow(() => { - mod = requireCJS("../../open-sse/lib/deepseek-pow-solver.cjs"); - }); - assert.ok(mod!, "module loaded"); - assert.equal(typeof mod!.U, "function", "expected exported constructor U"); -}); - -test("deepseek-pow-solver.cjs does not pollute globalThis.onmessage in Node (#2724)", () => { - const before = (globalThis as { onmessage?: unknown }).onmessage; - requireCJS("../../open-sse/lib/deepseek-pow-solver.cjs"); - const after = (globalThis as { onmessage?: unknown }).onmessage; - assert.equal(after, before, "onmessage must remain unchanged outside Worker context"); -}); diff --git a/tests/unit/deepseek-web.test.ts b/tests/unit/deepseek-web.test.ts index 17266edf00..b210a31086 100644 --- a/tests/unit/deepseek-web.test.ts +++ b/tests/unit/deepseek-web.test.ts @@ -648,24 +648,73 @@ test("execute with a new DeepSeek userToken restarts auto-refresh", async () => // ─── Abort handling ────────────────────────────────────────────────────── -test("execute: handles abort signal gracefully", async () => { +test("execute: propagates the request signal into an in-flight PoW search", async () => { const dsMod4 = await import("../../open-sse/executors/deepseek-web.ts"); - if (dsMod4.tokenCache) dsMod4.tokenCache.clear(); - const executor = new DeepSeekWebExecutor(); + dsMod4.tokenCache.clear(); + dsMod4.sessionCache.clear(); + const originalFetch = globalThis.fetch; const controller = new AbortController(); - controller.abort(); - const result = await executor.execute({ - model: "default", - body: { messages: [{ role: "user", content: "hi" }] }, - stream: true, - credentials: { apiKey: "test-token-abort" }, - signal: controller.signal, - }); - assert.ok(result.response, "Should return response"); - assert.ok( - result.response.status >= 400 || result.response.status === 499, - "Should indicate error or abort" - ); + let completionCalls = 0; + + globalThis.fetch = async (input) => { + const url = input.toString(); + if (url.includes("/users/current")) { + return new Response( + JSON.stringify({ code: 0, data: { biz_data: { token: "abort-access-token" } } }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + if (url.includes("/chat_session/create")) { + return new Response( + JSON.stringify({ + code: 0, + data: { biz_data: { chat_session: { id: "abort-session" } } }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + if (url.includes("/chat/create_pow_challenge")) { + setTimeout(() => controller.abort(), 0); + return new Response( + JSON.stringify({ + code: 0, + data: { + biz_data: { + challenge: { + algorithm: "DeepSeekHashV1", + challenge: "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + salt: "abort", + signature: "test-signature", + difficulty: 50_000, + expire_at: 1, + expire_after: 1, + target_path: "/api/v0/chat/completion", + }, + }, + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + if (url.includes("/chat/completion")) completionCalls += 1; + throw new Error(`Unexpected request after PoW cancellation: ${url}`); + }; + + try { + const result = await new DeepSeekWebExecutor().execute({ + model: "default", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: "test-token-abort" }, + signal: controller.signal, + }); + assert.equal(result.response.status, 499); + assert.equal(completionCalls, 0, "an aborted request must not reach completion"); + } finally { + globalThis.fetch = originalFetch; + dsMod4.tokenCache.clear(); + dsMod4.sessionCache.clear(); + } }); // ─── Search enabled ──────────────────────────────────────────────────────