From 9c8a0938a9d0269137e7bd674ea58a88bde44938 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Thu, 14 May 2026 09:26:14 -0600 Subject: [PATCH 1/9] feat(deploy): direct-HTTPS peer relay for streamed deploy_component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replicating a `deploy_component` with a streamed payload no longer goes through the WebSocket sendOperation path — that wraps the entire operation (including the payload Buffer) in a single WS frame, which can't carry payloads beyond Node's 2 GB cap. When core's deployComponent has staged the payload to a temp file (see the corresponding core change at feat/deploy-component-payload-staging), replicateOperation now relays to each peer over direct HTTPS: 1. Mint a short-lived operation token by calling `create_authentication_tokens` over the existing replication WS connection — the peer signs a token tied to whatever user authed that WS connection, so the auth model matches existing replication. 2. Open HTTPS to the peer's operations API (port 9925 by default, overridable per node) and stream a multipart/form-data deploy request with the staged payload as the file part. Peer processes it as a normal local deploy with `replicated: false` so it doesn't re-fan-out. 3. Per-peer success/failure is aggregated with Promise.allSettled — one peer failing doesn't abort the others, matching the per-peer- status semantics agreed for HarperFast/harper#524. Out of scope for this slice: - Transient-error retries per peer (basic fail-fast; can refine after we see real-world failure modes) - SSE re-emission of per-peer events on the origin's response stream (the SSE channel is in core via #531; the relay can plug into it as a follow-up) - restart_service relay (small payload, WS path is fine) Bumps the core submodule pointer to pick up the payload staging required for this to work. Co-Authored-By: Claude Opus 4.7 (1M context) --- core | 2 +- replication/deployRelay.ts | 266 +++++++++++++++++++++ replication/replicator.ts | 28 +++ unitTests/replication/deployRelay.test.mjs | 231 ++++++++++++++++++ 4 files changed, 526 insertions(+), 1 deletion(-) create mode 100644 replication/deployRelay.ts create mode 100644 unitTests/replication/deployRelay.test.mjs diff --git a/core b/core index 0c1b3b612..167f5b0e6 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 0c1b3b612dfd9fa9dc51ebe0e7a9338bb135c95c +Subproject commit 167f5b0e65cc12197244cf32a4548becfa32039e diff --git a/replication/deployRelay.ts b/replication/deployRelay.ts new file mode 100644 index 000000000..665a9940c --- /dev/null +++ b/replication/deployRelay.ts @@ -0,0 +1,266 @@ +import { createReadStream } from 'node:fs'; +import { stat } from 'node:fs/promises'; +import { request as httpsRequest } from 'node:https'; +import { request as httpRequest } from 'node:http'; +import { URL } from 'node:url'; +import { buildMultipartBody } from '../core/bin/multipartBuilder.ts'; +import { get } from '../core/utility/environment/environmentManager.js'; +import { CONFIG_PARAMS } from '../core/utility/hdbTerms.ts'; +import * as logger from '../core/utility/logging/harper_logger.js'; + +interface NodeLike { + name?: string; + url?: string; + host?: string; + port?: number; + verify_tls?: boolean; + rejectUnauthorized?: boolean; + /** Test/proxy override for the operations API base URL. */ + operationsApiUrl?: string; +} + +interface NodeRelayResult { + node: string | undefined; + status: 'success' | 'failed'; + message?: string; + reason?: string; + statusCode?: number; + [key: string]: unknown; +} + +/** + * Injectable dependencies — keeps relayDeployToNode unit-testable without mocking ESM + * modules. Production callers use the default (real `create_authentication_tokens` over + * the replication WS). + */ +export interface RelayDeps { + mintToken: (node: NodeLike) => Promise; +} + +const defaultDeps: RelayDeps = { + mintToken: mintOperationToken, +}; + +// CLI/transport-only fields that must never be replayed to a peer. The streaming-deploy +// origin's `req` carries a few internal flags (e.g. the staged payload path, progress +// emitter) that have no meaning on the peer side and would only confuse its validation. +const NON_FORWARDABLE_FIELDS = new Set([ + 'payload', // the Readable is exhausted on the origin; peers receive the file part + 'progress', // ProgressEmitter is local to the origin + 'hdb_user', // peer authenticates the request itself; don't forward our identity + 'fastifyResponse', + 'baseRequest', + 'baseResponse', +]); + +/** + * Relay a streamed `deploy_component` request to a single peer over direct HTTPS, + * bypassing the WebSocket replication frame which can't carry multi-GB payloads. + * + * Flow: + * 1. Mint a short-lived operation token via the existing replication WS connection + * (`create_authentication_tokens` runs against the peer's auth context, so the token + * it returns is scoped to the replication user the peer already trusts). + * 2. Re-stream the staged payload file as the file part of a multipart/form-data POST + * to the peer's operations API. The payload is read from disk fresh per relay attempt + * so retries (handled by the caller) get a usable stream. + * 3. Parse the JSON response and return per-peer status. + * + * The peer processes the request as a normal local deploy (with `replicated: false` so it + * doesn't fan out further). Failures here are returned as a `failed` result; the caller + * decides whether one peer failing aborts the whole deploy (see HarperFast/harper#524's + * "per-peer status with retry" semantics). + */ +export async function relayDeployToNode( + node: NodeLike, + req: Record, + payloadPath: string, + deps: RelayDeps = defaultDeps +): Promise { + const fields = buildForwardableFields(req); + let payloadSize: number; + try { + payloadSize = (await stat(payloadPath)).size; + } catch (err) { + return { + node: node.name, + status: 'failed', + reason: `staged payload missing: ${(err as Error).message}`, + }; + } + + let token: string; + try { + token = await deps.mintToken(node); + } catch (err) { + return { + node: node.name, + status: 'failed', + reason: `token mint failed: ${(err as Error).message ?? String(err)}`, + }; + } + + const target = resolveOperationsApiUrl(node); + const multipart = buildMultipartBody(fields, { + name: 'payload', + filename: 'package.tar.gz', + contentType: 'application/gzip', + stream: createReadStream(payloadPath), + }); + + try { + const response = await sendMultipart(target, token, multipart, node, payloadSize); + return { node: node.name, status: 'success', ...response }; + } catch (err: any) { + return { + node: node.name, + status: 'failed', + reason: err?.message ?? String(err), + statusCode: err?.statusCode, + }; + } +} + +async function mintOperationToken(node: NodeLike): Promise { + // `create_authentication_tokens` against an already-authenticated peer connection + // returns a token tied to the connection's user (no username/password needed). + // Dynamic-import to avoid pulling the full replicator (and its transitive deps) into + // every consumer of this module — production code that calls relayDeployToNode has the + // replicator loaded anyway, so the cost is paid once and cached. + const { sendOperationToNode } = await import('./replicator.ts'); + const response: any = await sendOperationToNode(node, { operation: 'create_authentication_tokens' }, undefined); + const token = response?.operation_token ?? response?.results?.operation_token; + if (!token || typeof token !== 'string') { + throw new Error('peer did not return an operation_token'); + } + return token; +} + +function buildForwardableFields(req: Record): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(req)) { + if (key.startsWith('_') || NON_FORWARDABLE_FIELDS.has(key)) continue; + out[key] = value; + } + // Critical: the peer must NOT re-replicate. Without this the deploy would fan out from + // each peer back to every other node, which would either loop or storm depending on the + // replication implementation. + out.replicated = false; + return out; +} + +function resolveOperationsApiUrl(node: NodeLike): URL { + // A node config can override the operations API URL directly (used by tests and by + // deployments that put the ops API behind a proxy). Otherwise fall back to the local + // node's configured ops API port; cluster topologies typically use uniform ports. + if (node.operationsApiUrl) return new URL(node.operationsApiUrl); + const securePort = get(CONFIG_PARAMS.OPERATIONSAPI_NETWORK_SECUREPORT); + const insecurePort = get(CONFIG_PARAMS.OPERATIONSAPI_NETWORK_PORT); + const port = node.port ?? securePort ?? insecurePort ?? 9925; + const protocol = securePort ? 'https:' : 'http:'; + const hostname = extractHostname(node); + return new URL(`${protocol}//${hostname}:${port}/`); +} + +function extractHostname(node: NodeLike): string { + if (node.host) return node.host; + if (node.url) { + try { + return new URL(node.url).hostname; + } catch { + // fall through + } + } + if (node.name) { + // node.name is sometimes "host" and sometimes "host:port" — strip the port. + const colon = node.name.lastIndexOf(':'); + return colon > 0 && /^\d+$/.test(node.name.slice(colon + 1)) ? node.name.slice(0, colon) : node.name; + } + throw new Error('node has no hostname (missing name/url/host)'); +} + +interface MultipartBody { + contentType: string; + stream: NodeJS.ReadableStream; +} + +function sendMultipart( + target: URL, + token: string, + multipart: MultipartBody, + node: NodeLike, + contentLengthHint: number +): Promise> { + return new Promise((resolve, reject) => { + const request = target.protocol === 'https:' ? httpsRequest : httpRequest; + // Per-node TLS verification flag, mirroring how setNode reads it (`verify_tls` from + // the node config maps to `rejectUnauthorized`). Default: verify, matching WS + // replication's default posture. + const verifyTls = node.rejectUnauthorized ?? node.verify_tls ?? true; + const req = request( + { + protocol: target.protocol, + hostname: target.hostname, + port: target.port || (target.protocol === 'https:' ? 443 : 80), + method: 'POST', + path: '/', + headers: { + 'Content-Type': multipart.contentType, + 'Transfer-Encoding': 'chunked', + 'Authorization': `Bearer ${token}`, + // SNI hint for the cluster-CA-verifying peer; matches WS replication. + 'Host': target.hostname, + }, + // Reuse the cluster's TLS trust posture: verify peer cert against the cluster + // CAs when verifyTls is enabled (default). The same flag governs replication WS. + rejectUnauthorized: verifyTls, + servername: target.hostname, + }, + (res) => { + let body = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => (body += chunk)); + res.on('end', () => { + const statusCode = res.statusCode ?? 0; + if (statusCode >= 200 && statusCode < 300) { + try { + resolve(JSON.parse(body)); + } catch { + resolve({ message: body }); + } + } else { + const err = new Error(extractErrorMessage(body) || `HTTP ${statusCode}`); + (err as any).statusCode = statusCode; + reject(err); + } + }); + } + ); + req.on('error', reject); + multipart.stream.on('error', (err) => { + req.destroy(err); + reject(err); + }); + logger.debug?.( + `Relaying deploy to ${node.name ?? target.hostname} via ${target.href} (~${formatBytes(contentLengthHint)})` + ); + multipart.stream.pipe(req); + }); +} + +function extractErrorMessage(body: string): string | undefined { + if (!body) return undefined; + try { + const parsed = JSON.parse(body); + return parsed?.error ?? parsed?.message ?? body.slice(0, 200); + } catch { + return body.slice(0, 200); + } +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GiB`; +} diff --git a/replication/replicator.ts b/replication/replicator.ts index 40481b503..65aadbf84 100644 --- a/replication/replicator.ts +++ b/replication/replicator.ts @@ -633,6 +633,34 @@ export async function replicateOperation(req) { 'to nodes', server.nodes.map((node) => node.name) ); + + // `deploy_component` with a staged payload is replicated via direct-HTTPS multipart + // streaming rather than the default WS sendOperation path — the latter buffers the + // whole operation into a single WS frame, which can't carry payloads larger than the + // 2 GB Buffer cap. The staged path is set by core's deployComponent when there's a + // streaming payload AND peers to replicate to. See HarperFast/harper#524. + const useDeployRelay = req.operation === 'deploy_component' && typeof req._stagedPayloadPath === 'string'; + if (useDeployRelay) { + const { relayDeployToNode } = await import('./deployRelay.ts'); + const payloadPath = req._stagedPayloadPath; + const replicatedResults = await Promise.allSettled( + server.nodes.map((node) => relayDeployToNode(node, req, payloadPath)) + ); + (response as any).replicated = replicatedResults.map((settledResult, index) => { + if (settledResult.status === 'rejected') { + return { + node: server.nodes[index]?.name, + status: 'failed', + reason: settledResult.reason?.toString?.() ?? String(settledResult.reason), + }; + } + const result = settledResult.value as { node?: string; [key: string]: unknown }; + if (!result.node) result.node = server.nodes[index]?.name; + return result; + }); + return response; + } + const replicatedResults = await Promise.allSettled( server.nodes.map((node) => { // do all the nodes in parallel diff --git a/unitTests/replication/deployRelay.test.mjs b/unitTests/replication/deployRelay.test.mjs new file mode 100644 index 000000000..5dde8a0d1 --- /dev/null +++ b/unitTests/replication/deployRelay.test.mjs @@ -0,0 +1,231 @@ +/** + * Unit test for the direct-HTTPS deploy relay. + * + * Spins up a local HTTP server playing the role of a peer's operations API, stubs the + * JWT mint function, and verifies that `relayDeployToNode` posts a multipart/form-data + * body containing the expected fields and the staged payload file, with the right + * Authorization header, and parses the response correctly. + * + * Run: `node --test unitTests/replication/deployRelay.test.mjs` + */ +import { test, describe } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { createServer } from 'node:http'; +import { mkdtemp, writeFile, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { relayDeployToNode } from '#src/replication/deployRelay'; + +/** + * Minimal multipart parser sufficient for tests — extracts field-name → string-value pairs + * and the single file part's bytes/filename/mime. Doesn't depend on busboy (which would + * mean adding a harper-pro dep just for tests). Assumes the request body fits in memory + * (true for the test-sized fixtures here). + */ +function parseMultipart(body, boundary) { + const out = { fields: {}, fileBytes: Buffer.alloc(0), fileFilename: undefined, fileMimeType: undefined }; + const sep = Buffer.from('\r\n--' + boundary); + const parts = splitBuffer(Buffer.concat([Buffer.from('\r\n'), body]), sep); + for (const part of parts) { + // Drop the leading empty segment and the trailing `--` closing marker. + if (part.length === 0 || part.equals(Buffer.from('--\r\n'))) continue; + // Each part: \r\nheader-line\r\nheader-line\r\n\r\nbody\r\n + const split = indexOfDouble(part); + if (split === -1) continue; + const headers = part.slice(0, split).toString('utf8'); + let value = part.slice(split + 4); + if (value.length >= 2 && value[value.length - 2] === 0x0d && value[value.length - 1] === 0x0a) { + value = value.slice(0, -2); + } + const nameMatch = /name="([^"]+)"/.exec(headers); + const filenameMatch = /filename="([^"]*)"/.exec(headers); + const typeMatch = /Content-Type:\s*([^\r\n]+)/i.exec(headers); + if (!nameMatch) continue; + if (filenameMatch) { + out.fileBytes = value; + out.fileFilename = filenameMatch[1]; + out.fileMimeType = typeMatch?.[1]; + } else { + out.fields[nameMatch[1]] = value.toString('utf8'); + } + } + return out; +} + +function splitBuffer(buf, sep) { + const out = []; + let start = 0; + while (start <= buf.length) { + const idx = buf.indexOf(sep, start); + if (idx === -1) { + out.push(buf.slice(start)); + break; + } + out.push(buf.slice(start, idx)); + start = idx + sep.length; + } + return out; +} + +function indexOfDouble(buf) { + for (let i = 0; i < buf.length - 3; i++) { + if (buf[i] === 0x0d && buf[i + 1] === 0x0a && buf[i + 2] === 0x0d && buf[i + 3] === 0x0a) return i; + } + return -1; +} + +async function withPeerServer(handler) { + const received = { + method: '', + url: '', + headers: {}, + fields: {}, + fileBytes: Buffer.alloc(0), + fileFilename: undefined, + fileMimeType: undefined, + }; + const server = createServer((req, res) => { + received.method = req.method ?? ''; + received.url = req.url ?? ''; + received.headers = req.headers; + const contentType = req.headers['content-type'] || ''; + if (typeof contentType === 'string' && contentType.startsWith('multipart/form-data')) { + const boundary = /boundary=([^;]+)/.exec(contentType)?.[1]; + if (!boundary) { + res.statusCode = 400; + res.end('no boundary'); + return; + } + const chunks = []; + req.on('data', (c) => chunks.push(c)); + req.on('end', () => { + const parsed = parseMultipart(Buffer.concat(chunks), boundary); + Object.assign(received, parsed); + const out = handler(received); + res.statusCode = out.status; + res.setHeader('content-type', 'application/json'); + res.end(typeof out.body === 'string' ? out.body : JSON.stringify(out.body)); + }); + } else { + res.statusCode = 415; + res.end('expected multipart'); + } + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve())); + const addr = server.address(); + if (!addr || typeof addr === 'string') throw new Error('no server address'); + const port = addr.port; + return { + port, + received, + close: () => new Promise((resolve) => server.close(() => resolve())), + }; +} + +describe('relayDeployToNode', () => { + test('streams a multipart deploy and parses the JSON response', async () => { + const tmp = await mkdtemp(join(tmpdir(), 'relay-test-')); + const payloadPath = join(tmp, 'payload.tar.gz'); + const payloadBytes = Buffer.alloc(100 * 1024).fill(0xab); + await writeFile(payloadPath, payloadBytes); + const server = await withPeerServer(() => ({ + status: 200, + body: { message: 'Successfully deployed: demo' }, + })); + try { + const node = { + name: 'peer-1', + host: '127.0.0.1', + port: server.port, + rejectUnauthorized: false, + }; + const result = await relayDeployToNode( + node, + { operation: 'deploy_component', project: 'demo', restart: true, payload: 'should-be-stripped' }, + payloadPath, + { mintToken: async () => 'test-jwt-token' } + ); + assert.equal(result.status, 'success'); + assert.equal(result.node, 'peer-1'); + assert.equal(result.message, 'Successfully deployed: demo'); + assert.equal(server.received.method, 'POST'); + assert.equal(server.received.headers.authorization, 'Bearer test-jwt-token'); + assert.equal(server.received.fields.operation, 'deploy_component'); + assert.equal(server.received.fields.project, 'demo'); + assert.equal(server.received.fields.restart, 'true', 'JSON-encoded booleans on the wire'); + assert.equal(server.received.fields.replicated, 'false', 'peer must NOT re-replicate'); + assert.equal(server.received.fields.payload, undefined, 'CLI/internal fields are stripped'); + assert.equal(server.received.fileBytes.length, payloadBytes.length, 'file part is intact'); + assert.deepEqual(server.received.fileBytes, payloadBytes); + assert.equal(server.received.fileFilename, 'package.tar.gz'); + assert.equal(server.received.fileMimeType, 'application/gzip'); + } finally { + await server.close(); + await rm(tmp, { recursive: true, force: true }); + } + }); + + test('returns a failed result when the peer responds with a 4xx/5xx', async () => { + const tmp = await mkdtemp(join(tmpdir(), 'relay-test-')); + const payloadPath = join(tmp, 'payload.tar.gz'); + await writeFile(payloadPath, 'data'); + const server = await withPeerServer(() => ({ + status: 500, + body: { error: 'Failed to install dependencies for demo' }, + })); + try { + const result = await relayDeployToNode( + { name: 'peer-1', host: '127.0.0.1', port: server.port, rejectUnauthorized: false }, + { operation: 'deploy_component', project: 'demo' }, + payloadPath, + { mintToken: async () => 'token' } + ); + assert.equal(result.status, 'failed'); + assert.equal(result.statusCode, 500); + assert.match(String(result.reason), /Failed to install dependencies/); + } finally { + await server.close(); + await rm(tmp, { recursive: true, force: true }); + } + }); + + test('returns a failed result when the token mint fails (no HTTP call attempted)', async () => { + const tmp = await mkdtemp(join(tmpdir(), 'relay-test-')); + const payloadPath = join(tmp, 'payload.tar.gz'); + await writeFile(payloadPath, 'data'); + let httpHit = false; + const server = await withPeerServer(() => { + httpHit = true; + return { status: 200, body: {} }; + }); + try { + const result = await relayDeployToNode( + { name: 'peer-1', host: '127.0.0.1', port: server.port, rejectUnauthorized: false }, + { operation: 'deploy_component', project: 'demo' }, + payloadPath, + { + mintToken: async () => { + throw new Error('peer rejected token request'); + }, + } + ); + assert.equal(result.status, 'failed'); + assert.match(String(result.reason), /token mint failed: peer rejected token request/); + assert.equal(httpHit, false, 'no HTTPS call should be made when token mint fails'); + } finally { + await server.close(); + await rm(tmp, { recursive: true, force: true }); + } + }); + + test('returns a failed result when the staged payload file is missing', async () => { + const result = await relayDeployToNode( + { name: 'peer-1', host: '127.0.0.1', port: 1, rejectUnauthorized: false }, + { operation: 'deploy_component', project: 'demo' }, + '/nonexistent/path/payload.tar.gz', + { mintToken: async () => 'token' } + ); + assert.equal(result.status, 'failed'); + assert.match(String(result.reason), /staged payload missing/); + }); +}); From 430a1a37b71bdebaa1261b2739f4aa5b9be0a0af Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Tue, 19 May 2026 07:58:24 -0600 Subject: [PATCH 2/9] chore: bump core submodule to slice-3a tip with synced lockfile --- core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core b/core index e6c5f7807..c293478f4 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit e6c5f7807688f979f4e107f5e71a1c26615f210a +Subproject commit c293478f47a9dff646a87968d31a476654db63c0 From c085018b4668b35e6b920cc76acd061d239dcd49 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Tue, 19 May 2026 08:04:22 -0600 Subject: [PATCH 3/9] chore: bump core submodule to pick up integration test fixture fix --- core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core b/core index c293478f4..f91a5faca 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit c293478f47a9dff646a87968d31a476654db63c0 +Subproject commit f91a5faca2066c6c5b245509d9faad7aa9d10822 From b30b21bd630c6596694901044fea39c980a19099 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Tue, 19 May 2026 22:41:25 -0600 Subject: [PATCH 4/9] chore: bump core submodule for #531's req.progress strip + headers guard --- core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core b/core index f91a5faca..e95512d2f 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit f91a5faca2066c6c5b245509d9faad7aa9d10822 +Subproject commit e95512d2fdfc3397fd9fee292b889015cc5bd971 From ec21a7a942343a9f86cf8b5531d19b0818c4dc03 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Tue, 19 May 2026 22:54:11 -0600 Subject: [PATCH 5/9] chore: bump core submodule for #531's upload progress + live install output --- core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core b/core index e95512d2f..5a75dc99a 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit e95512d2fdfc3397fd9fee292b889015cc5bd971 +Subproject commit 5a75dc99a07e22b3bc4727a64b2c02f963d66d58 From 40bd46af6e0afe0dc65bcd1bdf11b0ede78e99a5 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Tue, 19 May 2026 23:03:33 -0600 Subject: [PATCH 6/9] chore: bump core submodule to pick up SSE non-SSE fallback drain fix Cascades the fix from #531 (a320af514): drain the IncomingMessage when useSse=true but server returns non-SSE content (e.g. 401 auth failure). Co-Authored-By: Claude Opus 4.7 (1M context) --- core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core b/core index 5a75dc99a..fc8530ea1 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 5a75dc99a07e22b3bc4727a64b2c02f963d66d58 +Subproject commit fc8530ea11e41885d7b5dff0880a2cd8a1e5c3fe From 75c293b69a21a962ee07c6317007cc3cc8a13a9f Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Wed, 20 May 2026 03:15:50 -0600 Subject: [PATCH 7/9] chore: bump core submodule to pick up SSE UTF-8, disconnect cleanup, error stringify fixes Co-Authored-By: Claude Opus 4.7 (1M context) --- core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core b/core index fc8530ea1..1fdcf09c8 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit fc8530ea11e41885d7b5dff0880a2cd8a1e5c3fe +Subproject commit 1fdcf09c8ca99e27a3786042278a9e7bc337e7a9 From 725354f8ca45194573aa55bf7c7cc8fa4b026b87 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Wed, 20 May 2026 03:18:21 -0600 Subject: [PATCH 8/9] chore: bump core submodule to pick up prettier format fix Co-Authored-By: Claude Opus 4.7 (1M context) --- core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core b/core index 1fdcf09c8..39a59712f 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 1fdcf09c8ca99e27a3786042278a9e7bc337e7a9 +Subproject commit 39a59712f4779d01a56ee276777fbaf148e19d02 From b3e63bfe1ce3740d845fb0dd0f748d4ac7dcf7af Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Wed, 20 May 2026 04:33:12 -0600 Subject: [PATCH 9/9] chore: bump core submodule to pick up SSE multi-line data spec fix Co-Authored-By: Claude Opus 4.7 (1M context) --- core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core b/core index 39a59712f..e190a4838 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 39a59712f4779d01a56ee276777fbaf148e19d02 +Subproject commit e190a48382df83573db88ef131b1ff3f39957cc6