/** * agent/client.mjs — HAL 호출(서명·시계 차이·artifact 내려받기·전송 올리기/내려받기). * 모든 요청은 lib/serviceauth 로 서명한다(keyId 'agent:'). 응답의 Date/X-HAL-Time 으로 시계 차이를 재어 heartbeat 에 싣는다. * 큰 파일 업로드는 본문 해시를 서명에 넣지 않고(빈 본문 서명) X-Transfer-Sha256 헤더로 무결성을 전한다(HAL 이 받은 뒤 다시 계산해 비교). */ import { signHeaders } from '../lib/serviceauth.mjs'; import { createHash } from 'node:crypto'; import { createWriteStream, createReadStream, statSync } from 'node:fs'; import { pipeline } from 'node:stream/promises'; import { Readable } from 'node:stream'; export function createClient({ halUrl, agentId, secret, fetchImpl = fetch }) { const base = String(halUrl).replace(/\/$/, ''); const keyId = `agent:${agentId}`; let clockSkewMs = 0; function skew(r) { const t = Number(r.headers.get('x-hal-time') || 0) || Date.parse(r.headers.get('date') || ''); if (t) clockSkewMs = Date.now() - t; } async function request(method, path, body, { timeoutMs = 30000 } = {}) { const text = body == null ? undefined : JSON.stringify(body); const r = await fetchImpl(base + path, { method, headers: signHeaders(keyId, secret, method, path, text), body: text, signal: AbortSignal.timeout(timeoutMs) }); skew(r); let json = null; try { json = await r.json(); } catch (_) {} if (!r.ok || (json && json.ok === false)) { const e = new Error(`HAL ${method} ${path} → ${r.status} ${json?.error || ''} ${json?.message || ''}`.trim()); e.status = r.status; e.code = json?.error || `http_${r.status}`; throw e; } return json; } /** 파일 내려받기 + sha256 검증(expected 가 있으면) */ async function download(path, dest, expected = null) { // [E2E 결함 수정] 포털이 주는 번들 URL 은 절대 주소(토큰으로 보호)다. HAL 상대 경로일 때만 HAL 서명 헤더를 붙인다. const absolute = /^https?:\/\//i.test(path); const r = await fetchImpl(absolute ? path : base + path, { headers: absolute ? {} : signHeaders(keyId, secret, 'GET', path, undefined), signal: AbortSignal.timeout(3600000) }); if (!absolute) skew(r); if (!r.ok) { const e = new Error(`HAL 내려받기 실패 ${path} → ${r.status}`); e.code = `http_${r.status}`; throw e; } const hash = createHash('sha256'); const src = Readable.fromWeb(r.body); src.on('data', (c) => hash.update(c)); await pipeline(src, createWriteStream(dest, { mode: 0o600 })); const sum = hash.digest('hex'); const want = String(expected || r.headers.get('x-sha256') || '').replace(/^sha256:/i, '').toLowerCase(); if (want && want !== sum) throw Object.assign(new Error(`내려받은 파일 해시가 다릅니다(기대 ${want.slice(0, 12)}… 실제 ${sum.slice(0, 12)}…)`), { code: 'checksum_mismatch' }); return { sha256: sum, size: statSync(dest).size }; } /** 전송 올리기(스트리밍). 반환 { id, sha256, size } */ async function upload(transferId, file, { purpose = '' } = {}) { const hash = createHash('sha256'); for await (const c of createReadStream(file)) hash.update(c); const sum = hash.digest('hex'); const size = statSync(file).size; const path = `/agent/v1/transfers/${transferId}${purpose ? `?purpose=${encodeURIComponent(purpose)}` : ''}`; const r = await fetchImpl(base + path, { method: 'POST', headers: { ...signHeaders(keyId, secret, 'POST', path, ''), 'Content-Type': 'application/octet-stream', 'Content-Length': String(size), 'X-Transfer-Sha256': sum }, body: createReadStream(file), duplex: 'half', signal: AbortSignal.timeout(3600000) }); skew(r); const json = await r.json().catch(() => null); if (!r.ok) throw Object.assign(new Error(`전송 올리기 실패 ${r.status} ${json?.error || ''}`), { code: json?.error || `http_${r.status}` }); return { id: transferId, sha256: sum, size }; } const downloadTransfer = (id, dest, expected = null) => download(`/agent/v1/transfers/${id}`, dest, expected); return { request, download, upload, downloadTransfer, get clockSkewMs() { return clockSkewMs; }, agentId, base }; }