diff --git a/src/cli/commands/proxy.ts b/src/cli/commands/proxy.ts index b6e79bc58..f6baf5bf8 100644 --- a/src/cli/commands/proxy.ts +++ b/src/cli/commands/proxy.ts @@ -1,5 +1,6 @@ import { randomBytes } from 'node:crypto'; import { createDaemonProxyServer } from '../../daemon-proxy.ts'; +import { buildDaemonHttpBaseUrl } from '../../daemon/http-contract.ts'; import { ensureDaemon, resolveClientSettings } from '../../daemon-client-lifecycle.ts'; import { AppError } from '../../utils/errors.ts'; import type { CliFlags } from '../../utils/cli-flags.ts'; @@ -54,7 +55,7 @@ async function startProxy(flags: CliFlags): Promise { const proxyBaseUrl = `http://${formatHostForUrl(address.address)}:${address.port}`; return { proxyBaseUrl, - agentDeviceBaseUrl: `${proxyBaseUrl}/agent-device`, + agentDeviceBaseUrl: buildDaemonHttpBaseUrl(proxyBaseUrl), token, upstreamBaseUrl, stateDir: settings.paths.baseDir, diff --git a/src/daemon-artifacts.ts b/src/daemon-artifacts.ts index 2ebc5a1fb..cdb25ce55 100644 --- a/src/daemon-artifacts.ts +++ b/src/daemon-artifacts.ts @@ -5,6 +5,7 @@ import path from 'node:path'; import { pipeline } from 'node:stream/promises'; import { AppError } from './utils/errors.ts'; import type { DaemonArtifact, DaemonRequest, DaemonResponse } from './daemon/types.ts'; +import { buildDaemonHttpAuthHeaders } from './daemon/http-contract.ts'; import { uploadArtifact } from './upload-client.ts'; // Mirrors the current daemon RPC timeout, but artifact download timeouts may diverge. @@ -319,12 +320,7 @@ export async function downloadRemoteArtifact(params: { port: artifactUrl.port, method: 'GET', path: artifactUrl.pathname + artifactUrl.search, - headers: params.token - ? { - authorization: `Bearer ${params.token}`, - 'x-agent-device-token': params.token, - } - : undefined, + headers: buildDaemonHttpAuthHeaders(params.token), }, (res) => { if ((res.statusCode ?? 500) >= 400) { diff --git a/src/daemon-client-transport.ts b/src/daemon-client-transport.ts index 5ab6e01f0..ba073e37e 100644 --- a/src/daemon-client-transport.ts +++ b/src/daemon-client-transport.ts @@ -11,6 +11,7 @@ import { readDaemonSocketProgressResponse, shouldReadDaemonProgressStream, } from './daemon-client-progress.ts'; +import { buildDaemonHttpAuthHeaders, buildDaemonHttpUrl } from './daemon/http-contract.ts'; import { buildHttpRpcPayload, handleDaemonHttpResponseBody } from './daemon-client-rpc.ts'; import { handleRequestTimeout } from './daemon-client-timeout.ts'; import { isRemoteDaemon, type DaemonInfo } from './daemon-client-metadata.ts'; @@ -108,11 +109,7 @@ function readDaemonHttpHealth(info: DaemonInfo): Promise { ? REMOTE_DAEMON_HEALTHCHECK_TIMEOUT_MS : LOCAL_DAEMON_HEALTHCHECK_TIMEOUT_MS; return new Promise((resolve) => { - const headers: Record = {}; - if (info.baseUrl && info.token) { - headers.authorization = `Bearer ${info.token}`; - headers['x-agent-device-token'] = info.token; - } + const headers = info.baseUrl ? buildDaemonHttpAuthHeaders(info.token) : {}; const req = transport.request( { protocol: url.protocol, @@ -369,9 +366,8 @@ async function sendHttpRequest( 'content-type': 'application/json', 'content-length': Buffer.byteLength(rpcPayload), }; - if (info.baseUrl && info.token) { - headers.authorization = `Bearer ${info.token}`; - headers['x-agent-device-token'] = info.token; + if (info.baseUrl) { + Object.assign(headers, buildDaemonHttpAuthHeaders(info.token)); } return await new Promise((resolve, reject) => { @@ -444,9 +440,3 @@ async function sendHttpRequest( request.end(); }); } - -function buildDaemonHttpUrl(baseUrl: string, route: 'health' | 'rpc'): string { - // URL(base, relative) treats a base without trailing slash as a file path, so normalize to a directory-like base. - const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; - return new URL(route, normalizedBase).toString(); -} diff --git a/src/daemon-proxy.ts b/src/daemon-proxy.ts index 9cefd172f..f1b419d6c 100644 --- a/src/daemon-proxy.ts +++ b/src/daemon-proxy.ts @@ -4,6 +4,11 @@ import { pipeline } from 'node:stream/promises'; import { randomUUID } from 'node:crypto'; import { AppError, normalizeError } from './utils/errors.ts'; import { timingSafeStringEqual } from './utils/timing-safe-equal.ts'; +import { + DAEMON_HTTP_BASE_PATH, + buildDaemonHttpAuthHeaders, + buildDaemonHttpUrl, +} from './daemon/http-contract.ts'; import { buildDaemonHealthPayload } from './daemon/http-health.ts'; export type DaemonProxyOptions = { @@ -17,7 +22,7 @@ export type DaemonProxyOptions = { const DEFAULT_MAX_RPC_BODY_BYTES = 1024 * 1024; const DEFAULT_UPSTREAM_TIMEOUT_MS = 5 * 60 * 1000; -const DAEMON_PROXY_PREFIX = '/agent-device/'; +const DAEMON_PROXY_PREFIX = `${DAEMON_HTTP_BASE_PATH}/`; const FORWARDED_REQUEST_HEADERS = ['content-type', 'x-artifact-type', 'x-artifact-filename']; const FORWARDED_RESPONSE_HEADERS = ['content-type', 'content-disposition', 'x-request-id']; @@ -68,7 +73,7 @@ async function sendProxyHealth(res: ServerResponse, options: Required): Promise { - const upstreamUrl = new URL('health', `${options.upstreamBaseUrl}/`); + const upstreamUrl = new URL(buildDaemonHttpUrl(options.upstreamBaseUrl, 'health')); const response = await options.fetchImpl(upstreamUrl, { method: 'GET', headers: buildUpstreamHeaders({ headers: {} }, options.upstreamToken, '/health'), @@ -153,7 +158,7 @@ function normalizeToken(value: string, label: string): string { function resolveProxyRoute(requestUrl: string): string { const pathname = new URL(requestUrl, 'http://127.0.0.1').pathname; - if (pathname === '/agent-device') return '/'; + if (pathname === DAEMON_HTTP_BASE_PATH) return '/'; if (pathname.startsWith(DAEMON_PROXY_PREFIX)) { return `/${pathname.slice(DAEMON_PROXY_PREFIX.length)}`; } @@ -168,7 +173,7 @@ function isSupportedDaemonRoute(route: string, method: string | undefined): bool } function buildUpstreamUrl(upstreamBaseUrl: string, route: string, rawUrl: string): URL { - const upstreamUrl = new URL(route.replace(/^\//, ''), `${upstreamBaseUrl}/`); + const upstreamUrl = new URL(buildDaemonHttpUrl(upstreamBaseUrl, route)); const rawSearchIndex = rawUrl.indexOf('?'); if (rawSearchIndex >= 0) upstreamUrl.search = rawUrl.slice(rawSearchIndex); return upstreamUrl; @@ -187,8 +192,9 @@ function buildUpstreamHeaders( if (route === '/rpc' && !headers.has('content-type')) { headers.set('content-type', 'application/json'); } - headers.set('authorization', `Bearer ${upstreamToken}`); - headers.set('x-agent-device-token', upstreamToken); + for (const [name, value] of Object.entries(buildDaemonHttpAuthHeaders(upstreamToken))) { + headers.set(name, value); + } return headers; } diff --git a/src/daemon/__tests__/http-contract.test.ts b/src/daemon/__tests__/http-contract.test.ts new file mode 100644 index 000000000..92a67ced8 --- /dev/null +++ b/src/daemon/__tests__/http-contract.test.ts @@ -0,0 +1,37 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { + buildDaemonHttpAuthHeaders, + buildDaemonHttpBaseUrl, + buildDaemonHttpUrl, +} from '../http-contract.ts'; + +test('buildDaemonHttpBaseUrl appends the public agent-device base path', () => { + assert.equal( + buildDaemonHttpBaseUrl('https://example.trycloudflare.com'), + 'https://example.trycloudflare.com/agent-device', + ); + assert.equal( + buildDaemonHttpBaseUrl('http://127.0.0.1:4310/'), + 'http://127.0.0.1:4310/agent-device', + ); +}); + +test('buildDaemonHttpUrl preserves daemon base paths for remote routes', () => { + assert.equal( + buildDaemonHttpUrl('https://example.trycloudflare.com/agent-device', 'health'), + 'https://example.trycloudflare.com/agent-device/health', + ); + assert.equal( + buildDaemonHttpUrl('https://example.trycloudflare.com/agent-device/', '/rpc'), + 'https://example.trycloudflare.com/agent-device/rpc', + ); +}); + +test('buildDaemonHttpAuthHeaders writes both supported daemon auth headers', () => { + assert.deepEqual(buildDaemonHttpAuthHeaders(' token-1 '), { + authorization: 'Bearer token-1', + 'x-agent-device-token': 'token-1', + }); + assert.deepEqual(buildDaemonHttpAuthHeaders(''), {}); +}); diff --git a/src/daemon/http-contract.ts b/src/daemon/http-contract.ts new file mode 100644 index 000000000..2831eccdd --- /dev/null +++ b/src/daemon/http-contract.ts @@ -0,0 +1,19 @@ +export const DAEMON_HTTP_BASE_PATH = '/agent-device'; + +export function buildDaemonHttpBaseUrl(baseUrl: string): string { + return buildDaemonHttpUrl(baseUrl, DAEMON_HTTP_BASE_PATH); +} + +export function buildDaemonHttpUrl(baseUrl: string, route: string): string { + const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; + return new URL(route.replace(/^\/+/, ''), normalizedBase).toString(); +} + +export function buildDaemonHttpAuthHeaders(token: string | undefined): Record { + const normalizedToken = token?.trim(); + if (!normalizedToken) return {}; + return { + authorization: `Bearer ${normalizedToken}`, + 'x-agent-device-token': normalizedToken, + }; +} diff --git a/src/upload-client.ts b/src/upload-client.ts index 6680e66c6..d5905b5d4 100644 --- a/src/upload-client.ts +++ b/src/upload-client.ts @@ -9,6 +9,7 @@ import { pipeline } from 'node:stream/promises'; import { AppError } from './utils/errors.ts'; import { readNodeHttpResponseBody } from './utils/node-http.ts'; import { runCmd } from './utils/exec.ts'; +import { buildDaemonHttpAuthHeaders } from './daemon/http-contract.ts'; const UPLOAD_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes const UPLOAD_PREFLIGHT_TIMEOUT_MS = 30 * 1000; @@ -188,10 +189,7 @@ async function uploadLegacyArtifact(options: { 'x-artifact-hash-algorithm': ARTIFACT_HASH_ALGORITHM, 'transfer-encoding': 'chunked', }; - if (token) { - headers.authorization = `Bearer ${token}`; - headers['x-agent-device-token'] = token; - } + Object.assign(headers, buildDaemonHttpAuthHeaders(token)); const response = await streamFileToHttpRequest({ url: uploadUrl, @@ -225,10 +223,7 @@ async function requestUploadPreflight(options: { const headers: Record = { 'content-type': 'application/json', }; - if (options.token) { - headers.authorization = `Bearer ${options.token}`; - headers['x-agent-device-token'] = options.token; - } + Object.assign(headers, buildDaemonHttpAuthHeaders(options.token)); const response = await fetch(preflightUrl, { method: 'POST', @@ -517,10 +512,7 @@ async function finalizeDirectUpload(options: { const headers: Record = { 'content-type': 'application/json', }; - if (options.token) { - headers.authorization = `Bearer ${options.token}`; - headers['x-agent-device-token'] = options.token; - } + Object.assign(headers, buildDaemonHttpAuthHeaders(options.token)); const response = await fetch(finalizeUrl, { method: 'POST',