agent test

This commit is contained in:
Evgeny @ SimpleX Chat
2026-06-08 11:55:59 +00:00
parent 5f1216bf07
commit 8b7fc88cb8
2 changed files with 224 additions and 0 deletions
+164
View File
@@ -0,0 +1,164 @@
// Agent-level REPL for cross-language testing.
// Uses AgentClient + smp-ops layer (not raw SMP client).
// Reads commands from stdin, writes results to stdout.
//
// Commands:
// INIT <wsUrl> <keyHashHex> <userId>
// CREATE_QUEUE <connIdHex>
// SUBSCRIBE <connIdHex>
// RECV [timeoutMs]
// CB_ENCRYPT <dhSecretHex> <version> <bodyHex>
// CB_DECRYPT <dhSecretHex> <nonceHex> <ciphertextHex>
// CLOSE
import "fake-indexeddb/auto"
import {createInterface} from "readline"
import {newAgentClient, defaultAgentConfig, type AgentClient as AC} from "../dist/agent/client.js"
import {getSMPServerClient, agentCbEncrypt, agentCbDecrypt, newRcvQueue, subscribeQueues, type ServerMsg} from "../dist/agent/smp-ops.js"
import {openAgentStore} from "../dist/agent/store-idb.js"
import type {AgentStore} from "../dist/agent/store.js"
import type {RcvQueueSub} from "../dist/agent/subscriptions.js"
let agentClient: AC | null = null
let store: AgentStore | null = null
let serverUrl = ""
let serverKeyHash: Uint8Array = new Uint8Array(0)
let userId = 1
function toHex(bytes: Uint8Array): string {
return Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("")
}
function fromHex(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < hex.length; i += 2)
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16)
return bytes
}
async function waitForMsg(c: AC, timeoutMs: number): Promise<ServerMsg> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("timeout")), timeoutMs)
c.msgQ.dequeue().then(msg => {
clearTimeout(timer)
resolve(msg as ServerMsg)
}).catch(reject)
})
}
async function parseLine(line: string): Promise<string> {
const parts = line.trim().split(" ")
const cmd = parts[0]
try {
switch (cmd) {
case "INIT": {
serverUrl = parts[1]
serverKeyHash = fromHex(parts[2])
userId = parseInt(parts[3], 10)
store = await openAgentStore()
await store.createUserRecord()
agentClient = newAgentClient(defaultAgentConfig, store, new Map([[userId, {
storageSrvs: [[null, {server: parts[1], auth: null}]],
proxySrvs: [[null, {server: parts[1], auth: null}]],
knownHosts: new Set(),
}]]) as any)
const conn = await getSMPServerClient(agentClient, userId, parts[1], serverKeyHash, parts[1])
return "ok: " + toHex(conn.client.sessionId)
}
case "CREATE_QUEUE": {
if (!agentClient || !store) return "error: not initialized"
const connId = fromHex(parts[1])
await store.createNewConn({
connId, connMode: "INV", userId, smpAgentVersion: 8,
enableNtfs: true, duplexHandshake: true, deleted: false,
ratchetSyncState: "ok", pqSupport: false,
lastInternalMsgId: 0, lastInternalRcvMsgId: 0, lastInternalSndMsgId: 0,
lastExternalSndMsgId: 0, lastRcvMsgHash: new Uint8Array(0), lastSndMsgHash: new Uint8Array(0),
}, "INV")
const result = await newRcvQueue(agentClient, userId, connId, serverUrl, serverKeyHash, serverUrl, true)
await store.addConnRcvQueue(connId, {
host: result.rcvQueue.host, port: result.rcvQueue.port,
rcvId: result.rcvQueue.rcv_id, connId,
rcvPrivateKey: result.rcvQueue.rcv_private_key,
rcvDhSecret: result.rcvQueue.rcv_dh_secret,
e2ePrivKey: result.rcvQueue.e2e_priv_key,
e2eDhSecret: null,
sndId: result.rcvQueue.snd_id,
sndKey: null, status: "new",
smpClientVersion: result.rcvQueue.smp_client_version,
dbQueueId: 0, primary: true,
replaceRcvQueueId: null, queueMode: result.rcvQueue.queue_mode,
serverKeyHash, lastBrokerTs: null,
}, "SMSubscribe")
return "ok: " + toHex(result.rcvQueue.rcv_id) + " " + toHex(result.sndId) + " " + toHex(result.e2eDhKey)
}
case "SUBSCRIBE": {
if (!agentClient || !store) return "error: not initialized"
const connId = fromHex(parts[1])
const conn = await store.getConn(connId)
if (!conn) return "error: connection not found"
const rcvQueues = conn.rcvQueues as any[]
if (rcvQueues.length === 0) return "error: no rcv queues"
const subs: RcvQueueSub[] = rcvQueues.map((rq: any) => ({
userId, connId: rq.conn_id, server: serverUrl,
rcvId: rq.rcv_id, rcvPrivateKey: rq.rcv_private_key,
status: rq.status, enableNtfs: true, clientNoticeId: null,
dbQueueId: rq.rcv_queue_id, primary: !!rq.rcv_primary,
dbReplaceQueueId: null,
}))
await subscribeQueues(agentClient, userId, subs)
return "ok"
}
case "RECV": {
if (!agentClient) return "error: not initialized"
const timeoutMs = parts[1] ? parseInt(parts[1], 10) : 5000
const msg = await waitForMsg(agentClient, timeoutMs)
return "ok: " + toHex(msg.entityId) + " " + JSON.stringify(msg.msg)
}
case "CB_ENCRYPT": {
const dhSecret = fromHex(parts[1])
const version = parseInt(parts[2], 10)
const body = fromHex(parts[3])
const envelope = agentCbEncrypt(dhSecret, version, null, body)
return "ok: " + toHex(envelope)
}
case "CB_DECRYPT": {
const dhSecret = fromHex(parts[1])
const nonce = fromHex(parts[2])
const ct = fromHex(parts[3])
const pt = agentCbDecrypt(dhSecret, nonce, ct)
return "ok: " + toHex(pt)
}
case "CLOSE": {
if (agentClient) {
agentClient.active = false
for (const [, sv] of agentClient.smpClients as Map<string, any>) {
if (sv.value?.client) sv.value.client.close()
}
}
return "ok"
}
default:
return "error: unknown command: " + cmd
}
} catch (e: any) {
return "error: " + (e?.message || String(e))
}
}
const rl = createInterface({input: process.stdin, output: process.stdout, terminal: false})
rl.on("line", async (line: string) => {
const result = await parseLine(line)
process.stdout.write(result + "\n")
})
rl.on("close", () => process.exit(0))
+60
View File
@@ -237,6 +237,14 @@ spawnJsClient = do
hSetBuffering hOut LineBuffering
pure (hIn, hOut, ph)
spawnJsAgent :: IO (Handle, Handle, ProcessHandle)
spawnJsAgent = do
let cp = (proc "node" ["dist-test/agent-repl.js"]) {cwd = Just "smp-web", std_in = CreatePipe, std_out = CreatePipe, std_err = Inherit}
(Just hIn, Just hOut, _, ph) <- createProcess cp
hSetBuffering hIn LineBuffering
hSetBuffering hOut LineBuffering
pure (hIn, hOut, ph)
destroyJsRatchet :: TestPeer -> IO ()
destroyJsRatchet (TestPeerJS _ _ ph) = terminateProcess ph
destroyJsRatchet _ = pure ()
@@ -1883,3 +1891,55 @@ smpWebTests_ = do
_ <- jsCmd rcvIn rcvOut "CLOSE"
terminateProcess rcvPh
describe "agent-repl" $ do
it "agentCbEncrypt cross-language: TS encrypts, Haskell decrypts" $ do
g <- C.newRandom
-- Generate shared DH secret
(rcvPub, _rcvPriv) <- atomically $ C.generateKeyPair @'C.X25519 g
(_sndPub, sndPriv) <- atomically $ C.generateKeyPair @'C.X25519 g
let dhSecret = C.dh' rcvPub sndPriv
C.DhSecretX25519 dhSecretRaw = dhSecret
dhSecretBytes = BA.convert dhSecretRaw :: B.ByteString
plaintext = "hello from typescript"
versionInt = 18 :: Int
-- TS: encrypt
(hIn, hOut, ph) <- spawnJsAgent
tsResp <- jsCmd hIn hOut $ "CB_ENCRYPT " <> bsToHex dhSecretBytes <> " " <> show versionInt <> " " <> bsToHex plaintext
let tsParts = words tsResp
head tsParts `shouldBe` "ok:"
let envelopeHex = tsParts !! 1
envelopeBytes = hexToBS envelopeHex
-- Haskell: decode ClientMsgEnvelope and decrypt
ClientMsgEnvelope {cmHeader = PubHeader ver _, cmNonce = nonce, cmEncBody = encBody} <- either fail pure $ smpDecode envelopeBytes
show ver `shouldBe` "Version 18"
decrypted <- either (fail . show) pure $ C.cbDecrypt dhSecret nonce encBody
-- The plaintext is padded — first bytes should match
B.take (B.length plaintext) decrypted `shouldBe` plaintext
_ <- jsCmd hIn hOut "CLOSE"
terminateProcess ph
it "agentCbEncrypt cross-language: Haskell encrypts, TS decrypts" $ do
g <- C.newRandom
-- Generate shared DH secret
(rcvPub, _rcvPriv) <- atomically $ C.generateKeyPair @'C.X25519 g
(_sndPub, sndPriv) <- atomically $ C.generateKeyPair @'C.X25519 g
let dhSecret = C.dh' rcvPub sndPriv
C.DhSecretX25519 dhSecretRaw = dhSecret
dhSecretBytes = BA.convert dhSecretRaw :: B.ByteString
plaintext = "hello from haskell"
-- Haskell: encrypt
cbNonce <- atomically $ C.randomCbNonce g
encBody <- either (fail . show) pure $ C.cbEncrypt dhSecret cbNonce plaintext 16000
let C.CbNonce nonceBytes = cbNonce
-- TS: decrypt
(hIn, hOut, ph) <- spawnJsAgent
tsResp <- jsCmd hIn hOut $ "CB_DECRYPT " <> bsToHex dhSecretBytes <> " " <> bsToHex nonceBytes <> " " <> bsToHex encBody
let tsParts = words tsResp
head tsParts `shouldBe` "ok:"
let decryptedHex = tsParts !! 1
decrypted = hexToBS decryptedHex
-- Decrypted is padded — first bytes should match
B.take (B.length plaintext) decrypted `shouldBe` plaintext
_ <- jsCmd hIn hOut "CLOSE"
terminateProcess ph