2026-10-26 11:58:15 +00:00
/**
* agent/client.mjs — HAL 호출(서명·시계 차이·artifact 내려받기·전송 올리기/내려받기).
* 모든 요청은 lib/serviceauth 로 서명한다(keyId 'agent:<id>'). 응답의 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 ) {
2026-10-26 13:13:39 +00:00
// [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 );
2026-10-26 11:58:15 +00:00
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 };
}