diff --git a/packages/contracts/src/daemon-http.test.ts b/packages/contracts/src/daemon-http.test.ts index 9f3b25b214..eb54322728 100644 --- a/packages/contracts/src/daemon-http.test.ts +++ b/packages/contracts/src/daemon-http.test.ts @@ -4,6 +4,7 @@ import { buildDaemonHealthPayload, buildDaemonHttpAuthHeaders, buildDaemonHttpBaseUrl, + buildDaemonHttpLeaseHeaders, buildDaemonHttpTenantHeaders, buildDaemonHttpUrl, buildRemoteTempArtifactDirPath, @@ -49,6 +50,14 @@ test('buildDaemonHttpTenantHeaders omits blank tenant identities', () => { assert.deepEqual(buildDaemonHttpTenantHeaders(''), {}); }); +test('buildDaemonHttpLeaseHeaders names a held lease and omits a blank one', () => { + assert.deepEqual(buildDaemonHttpLeaseHeaders(' lease-1 '), { + 'x-agent-device-lease-id': 'lease-1', + }); + assert.deepEqual(buildDaemonHttpLeaseHeaders(''), {}); + assert.deepEqual(buildDaemonHttpLeaseHeaders(undefined), {}); +}); + test('buildDaemonHealthPayload takes the version from its caller and keeps the payload shape', () => { assert.deepEqual(buildDaemonHealthPayload('agent-device-daemon', '0.20.9'), { ok: true, diff --git a/packages/contracts/src/daemon-http.ts b/packages/contracts/src/daemon-http.ts index 86772a61ad..e7e7ef477b 100644 --- a/packages/contracts/src/daemon-http.ts +++ b/packages/contracts/src/daemon-http.ts @@ -1,13 +1,17 @@ import path from 'node:path'; // The daemon HTTP wire vocabulary shared by the daemon server, the remote -// proxy, and every client that talks to them: the base path, the tenant and -// network-access header names, the URL/auth/tenant header builders, the +// proxy, and every client that talks to them: the base path, the tenant, lease +// and network-access header names, the URL/auth/tenant/lease header builders, the // /health payload, and the temp artifact paths a remote client names on the // daemon host. Client and server must agree on all of it, so neither side // owns it (ADR 0006). export const DAEMON_HTTP_BASE_PATH = '/agent-device'; export const DAEMON_HTTP_TENANT_HEADER = 'x-agent-device-tenant'; +// Names the lease a non-RPC request (upload, artifact download, request diagnostics) belongs to, +// as RPC calls name it in their body. Neither the daemon nor the proxy reads it; it lets a gateway +// in front of many daemons route those requests to the one holding the lease. +const DAEMON_HTTP_LEASE_HEADER = 'x-agent-device-lease-id'; export const DAEMON_HTTP_NETWORK_ACCESS_HEADER = 'x-agent-device-network-access'; export const DAEMON_HTTP_PUBLIC_NETWORK_ACCESS = 'public-only'; @@ -35,6 +39,12 @@ export function buildDaemonHttpTenantHeaders(tenantId: string | undefined): Reco return { [DAEMON_HTTP_TENANT_HEADER]: normalizedTenantId }; } +export function buildDaemonHttpLeaseHeaders(leaseId: string | undefined): Record { + const normalizedLeaseId = leaseId?.trim(); + if (!normalizedLeaseId) return {}; + return { [DAEMON_HTTP_LEASE_HEADER]: normalizedLeaseId }; +} + // See docs/adr/0006-daemon-rpc-protocol-version.md before changing this value. // Enforced, not just documented: `test/wire-compat/` digests the declarations // that cross this boundary and fails when one changes shape without a bump or diff --git a/src/__tests__/upload-client.test.ts b/src/__tests__/upload-client.test.ts index 3fe44bdc36..1458ac4cad 100644 --- a/src/__tests__/upload-client.test.ts +++ b/src/__tests__/upload-client.test.ts @@ -918,6 +918,152 @@ test('uploadArtifact uploads APK, AAB, and IPA files without wrapping them', asy } }); +/** + * A daemon fake that answers every upload route and records the lease header each request carried, + * keyed by `METHOD path`. The direct ticket points at `ticketOrigin` (the daemon's own by default). + */ +async function startLeaseRecordingUploadServer(options: { + preflight: 'direct' | 'unsupported'; + ticketOrigin?: (baseUrl: string) => string; +}): Promise<{ + baseUrl: string; + close: () => Promise; + leaseHeaders: Map; +}> { + const leaseHeaders = new Map(); + const server = await startServer(async (req, res) => { + leaseHeaders.set(`${req.method} ${req.url}`, req.headers['x-agent-device-lease-id']); + await readRequestBody(req); + if (req.method === 'POST' && req.url === '/upload/preflight') { + if (options.preflight === 'unsupported') { + res.statusCode = 404; + res.end('not found'); + return; + } + const origin = (options.ticketOrigin ?? ((baseUrl) => baseUrl))(server.baseUrl); + sendJson(res, { + ok: true, + cacheHit: false, + uploadId: 'ticket-1', + upload: { url: `${origin}/upload/direct/ticket-1`, headers: {} }, + }); + return; + } + if (req.method === 'PUT' && req.url === '/upload/direct/ticket-1') { + res.statusCode = 200; + res.end('ok'); + return; + } + if (req.method === 'POST' && (req.url === '/upload/finalize' || req.url === '/upload')) { + sendJson(res, { ok: true, uploadId: 'upload-done' }); + return; + } + res.statusCode = 404; + res.end('not found'); + }); + return { ...server, leaseHeaders }; +} + +test('uploadArtifact names the held lease on preflight, direct upload and finalize', async () => { + const artifactPath = createTempFile('app.apk', 'leased-direct-payload'); + const server = await startLeaseRecordingUploadServer({ preflight: 'direct' }); + try { + const uploadId = await uploadArtifact({ + localPath: artifactPath, + baseUrl: server.baseUrl, + token: TEST_TOKEN, + leaseId: 'lease-1', + }); + assert.equal(uploadId, 'upload-done'); + assert.deepEqual(Object.fromEntries(server.leaseHeaders), { + 'POST /upload/preflight': 'lease-1', + 'PUT /upload/direct/ticket-1': 'lease-1', + 'POST /upload/finalize': 'lease-1', + }); + } finally { + await server.close(); + } +}); + +test('uploadArtifact names the held lease on the legacy upload', async () => { + const artifactPath = createTempFile('app.apk', 'leased-legacy-payload'); + const server = await startLeaseRecordingUploadServer({ + preflight: 'unsupported', + }); + try { + await uploadArtifact({ + localPath: artifactPath, + baseUrl: server.baseUrl, + token: TEST_TOKEN, + leaseId: 'lease-1', + }); + assert.deepEqual(Object.fromEntries(server.leaseHeaders), { + 'POST /upload/preflight': 'lease-1', + 'POST /upload': 'lease-1', + }); + } finally { + await server.close(); + } +}); + +test('uploadArtifact sends no lease header when no lease is held', async () => { + const directPath = createTempFile('app.apk', 'unleased-direct-payload'); + const legacyPath = createTempFile('app.apk', 'unleased-legacy-payload'); + const direct = await startLeaseRecordingUploadServer({ preflight: 'direct' }); + const legacy = await startLeaseRecordingUploadServer({ + preflight: 'unsupported', + }); + try { + await uploadArtifact({ + localPath: directPath, + baseUrl: direct.baseUrl, + token: TEST_TOKEN, + }); + await uploadArtifact({ + localPath: legacyPath, + baseUrl: legacy.baseUrl, + token: TEST_TOKEN, + }); + assert.deepEqual(Object.fromEntries(direct.leaseHeaders), { + 'POST /upload/preflight': undefined, + 'PUT /upload/direct/ticket-1': undefined, + 'POST /upload/finalize': undefined, + }); + assert.deepEqual(Object.fromEntries(legacy.leaseHeaders), { + 'POST /upload/preflight': undefined, + 'POST /upload': undefined, + }); + } finally { + await direct.close(); + await legacy.close(); + } +}); + +test('uploadArtifact keeps the lease off a direct upload ticket on another origin', async () => { + const artifactPath = createTempFile('app.apk', 'foreign-ticket-payload'); + // `localhost` and `127.0.0.1` reach the same fake but are different origins, as a signed + // storage URL would be. + const server = await startLeaseRecordingUploadServer({ + preflight: 'direct', + ticketOrigin: (baseUrl) => baseUrl.replace('127.0.0.1', 'localhost'), + }); + try { + await uploadArtifact({ + localPath: artifactPath, + baseUrl: server.baseUrl, + token: TEST_TOKEN, + leaseId: 'lease-1', + }); + assert.deepEqual(Object.fromEntries(server.leaseHeaders), { + 'POST /upload/preflight': 'lease-1', + 'PUT /upload/direct/ticket-1': undefined, + 'POST /upload/finalize': 'lease-1', + }); + } finally { + await server.close(); + } +}); + function createTempDir(): string { const dir = mkdtempForTestSync(`agent-device-upload-client-${randomUUID()}-`); tempDirs.push(dir); diff --git a/src/daemon-client/__tests__/daemon-client-lease-header.test.ts b/src/daemon-client/__tests__/daemon-client-lease-header.test.ts new file mode 100644 index 0000000000..699bd8b44b --- /dev/null +++ b/src/daemon-client/__tests__/daemon-client-lease-header.test.ts @@ -0,0 +1,200 @@ +/** + * A remote daemon's upload, artifact download and request diagnostics calls are plain HTTP, so + * the lease they belong to cannot ride in an RPC body. These prove `sendToDaemon` names the held + * lease on each of them in a header — what lets a gateway in front of many daemons route them — + * and sends no such header when no lease is held. + */ + +import { afterEach, test } from 'vitest'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import http from 'node:http'; +import path from 'node:path'; +import { + closeLoopbackServer, + listenOnLoopback, + skipWhenLoopbackUnavailable, +} from '@agent-device/test-kit/loopback'; +import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; +import { sendToDaemon } from '../daemon-client.ts'; + +const DAEMON_TOKEN = 'remote-secret'; +const LEASE_HEADER = 'x-agent-device-lease-id'; + +type SeenRequest = { route: string; leaseHeader: string | undefined }; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs) fs.rmSync(dir, { recursive: true, force: true }); + tempDirs.length = 0; +}); + +function tempDir(prefix: string): string { + const dir = mkdtempForTestSync(prefix); + tempDirs.push(dir); + return dir; +} + +/** + * A remote daemon fake: `install` succeeds after a legacy upload, `screenshot` returns one + * downloadable artifact, and `clipboard` fails naming a diagnostics record. Every request other + * than `/health` and `/rpc` is recorded with the lease header it carried. + */ +async function withRemoteDaemon( + run: (baseUrl: string, seen: SeenRequest[]) => Promise, +): Promise { + const seen: SeenRequest[] = []; + const server = http.createServer((req, res) => { + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => { + const route = `${req.method} ${req.url}`; + if (req.url === '/health') { + res.statusCode = 200; + res.end(); + return; + } + if (req.url !== '/rpc') { + const leaseHeader = req.headers[LEASE_HEADER]; + seen.push({ + route, + leaseHeader: typeof leaseHeader === 'string' ? leaseHeader : undefined, + }); + } + res.setHeader('content-type', 'application/json'); + if (req.url === '/upload/preflight') { + res.statusCode = 404; + res.end('not found'); + return; + } + if (req.url === '/upload') { + res.end(JSON.stringify({ ok: true, uploadId: 'upload-1' })); + return; + } + if (req.url?.startsWith('/artifacts/')) { + res.end('png-bytes'); + return; + } + if (req.url?.endsWith('/diagnostics')) { + res.end('{"phase":"request_failed"}\n'); + return; + } + const rpc = JSON.parse(Buffer.concat(chunks).toString('utf8')) as { + id: string; + params?: { command?: string }; + }; + res.end(JSON.stringify({ jsonrpc: '2.0', id: rpc.id, ...rpcAnswer(rpc) })); + }); + }); + try { + const port = await listenOnLoopback(server); + await run(`http://127.0.0.1:${port}`, seen); + } finally { + await closeLoopbackServer(server); + } +} + +function rpcAnswer(rpc: { id: string; params?: { command?: string } }): Record { + if (rpc.params?.command === 'screenshot') { + return { + result: { + ok: true, + data: { + artifacts: [{ field: 'path', artifactId: 'shot-1', fileName: 'shot.png' }], + }, + }, + }; + } + if (rpc.params?.command === 'clipboard') { + return { + error: { + message: 'clipboard failed', + data: { + code: 'COMMAND_FAILED', + message: 'clipboard failed', + diagnosticsRecord: { session: 'default', requestId: rpc.id }, + }, + }, + }; + } + return { result: { ok: true, data: {} } }; +} + +async function sendRemote( + baseUrl: string, + request: { + command: string; + positionals: string[]; + flags?: Record; + }, + leaseId: string | undefined, +): Promise { + await sendToDaemon( + { + session: 'default', + command: request.command, + positionals: request.positionals, + flags: { + platform: 'android', + daemonBaseUrl: baseUrl, + stateDir: tempDir('agent-device-lease-header-state-'), + ...request.flags, + }, + meta: { + requestId: `req-${request.command}`, + ...(leaseId ? { leaseId } : {}), + }, + } as Parameters[0], + { authToken: DAEMON_TOKEN }, + ).catch((error: unknown) => { + // `clipboard` fails on purpose; only its diagnostics fetch is under test. + if (request.command !== 'clipboard') throw error; + }); +} + +async function runEveryRequestKind(leaseId: string | undefined): Promise { + let observed: SeenRequest[] = []; + await withRemoteDaemon(async (baseUrl, seen) => { + const apkPath = path.join(tempDir('agent-device-lease-header-apk-'), 'app.apk'); + fs.writeFileSync(apkPath, 'apk-bytes'); + const screenshotPath = path.join(tempDir('agent-device-lease-header-shot-'), 'shot.png'); + + await sendRemote(baseUrl, { command: 'install', positionals: [apkPath] }, leaseId); + await sendRemote(baseUrl, { command: 'screenshot', positionals: [screenshotPath] }, leaseId); + await sendRemote(baseUrl, { command: 'clipboard', positionals: ['read'] }, leaseId); + assert.equal(fs.readFileSync(screenshotPath, 'utf8'), 'png-bytes'); + observed = seen; + }); + return observed; +} + +test('a remote upload, artifact download and diagnostics fetch name the held lease', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + + const seen = await runEveryRequestKind('lease-1'); + assert.deepEqual(seen, [ + { route: 'POST /upload/preflight', leaseHeader: 'lease-1' }, + { route: 'POST /upload', leaseHeader: 'lease-1' }, + { route: 'GET /artifacts/shot-1', leaseHeader: 'lease-1' }, + { + route: 'GET /sessions/default/requests/req-clipboard/diagnostics', + leaseHeader: 'lease-1', + }, + ]); +}); + +test('a remote upload, artifact download and diagnostics fetch send no lease header without a lease', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + + const seen = await runEveryRequestKind(undefined); + assert.deepEqual(seen, [ + { route: 'POST /upload/preflight', leaseHeader: undefined }, + { route: 'POST /upload', leaseHeader: undefined }, + { route: 'GET /artifacts/shot-1', leaseHeader: undefined }, + { + route: 'GET /sessions/default/requests/req-clipboard/diagnostics', + leaseHeader: undefined, + }, + ]); +}); diff --git a/src/daemon-client/daemon-client-rpc.ts b/src/daemon-client/daemon-client-rpc.ts index 8da929e319..29808e9c46 100644 --- a/src/daemon-client/daemon-client-rpc.ts +++ b/src/daemon-client/daemon-client-rpc.ts @@ -91,7 +91,12 @@ async function rejectDaemonHttpRpcError( return; } const localized = await localizeRemoteDaemonError(payload, { - endpoint: { baseUrl: info.baseUrl, token: info.token, tenantId: req.meta?.tenantId }, + endpoint: { + baseUrl: info.baseUrl, + token: info.token, + tenantId: req.meta?.tenantId, + leaseId: leaseScopeFromRequest(req).leaseId, + }, stateDir, requestId, }); diff --git a/src/remote/__tests__/artifact-download.test.ts b/src/remote/__tests__/artifact-download.test.ts index a61703bf9e..3d1a0d8180 100644 --- a/src/remote/__tests__/artifact-download.test.ts +++ b/src/remote/__tests__/artifact-download.test.ts @@ -166,3 +166,28 @@ test('downloadRemoteArtifactFromUrl removes directory staging when a 2xx body fa fs.rmSync(tempRoot, { recursive: true, force: true }); } }); + +test('downloadRemoteArtifactFromUrl names the held lease and sends no lease header without one', async (t) => { + if (await skipWhenLoopbackUnavailable(t, 'artifact download lease header coverage')) return; + const tempRoot = mkdtempForTestSync('agent-device-artifact-lease-header-'); + const leaseHeaders: unknown[] = []; + const server = http.createServer((req, res) => { + leaseHeaders.push(req.headers['x-agent-device-lease-id']); + res.statusCode = 200; + res.end('png-bytes'); + }); + const port = await listenOnLoopback(server); + + try { + await downloadTo(tempRoot, port, { + requestScope: { requestId: 'req-leased', leaseId: 'lease-1' }, + }); + await downloadTo(tempRoot, port, { + requestScope: { requestId: 'req-unleased' }, + }); + assert.deepEqual(leaseHeaders, ['lease-1', undefined]); + } finally { + await closeLoopbackServer(server); + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); diff --git a/src/remote/__tests__/remote-request-diagnostics.test.ts b/src/remote/__tests__/remote-request-diagnostics.test.ts index 562e1e291f..0465dd7d9e 100644 --- a/src/remote/__tests__/remote-request-diagnostics.test.ts +++ b/src/remote/__tests__/remote-request-diagnostics.test.ts @@ -148,3 +148,33 @@ test('a daemon that names no record still never surfaces its own path', async () fs.rmSync(stateDir, { recursive: true, force: true }); } }); + +test('the record fetch names the held lease and sends no lease header without one', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + + const stateDir = mkdtempForTestSync('agent-device-remote-diagnostics-'); + const leaseHeaders: unknown[] = []; + const server = http.createServer((req, res) => { + leaseHeaders.push(req.headers['x-agent-device-lease-id']); + res.statusCode = 200; + res.end(RECORD); + }); + try { + const port = await listenOnLoopback(server); + const baseUrl = `http://127.0.0.1:${port}`; + const leased = await localizeRemoteDaemonError(remoteError(), { + endpoint: { baseUrl, token: 'daemon-secret', leaseId: 'lease-1' }, + stateDir, + }); + const unleased = await localizeRemoteDaemonError(remoteError(), { + endpoint: { baseUrl, token: 'daemon-secret' }, + stateDir, + }); + assert.equal(typeof leased.logPath, 'string'); + assert.equal(typeof unleased.logPath, 'string'); + assert.deepEqual(leaseHeaders, ['lease-1', undefined]); + } finally { + await closeLoopbackServer(server); + fs.rmSync(stateDir, { recursive: true, force: true }); + } +}); diff --git a/src/remote/artifact-download.ts b/src/remote/artifact-download.ts index eb42f4fc24..36c700e6f6 100644 --- a/src/remote/artifact-download.ts +++ b/src/remote/artifact-download.ts @@ -9,6 +9,7 @@ import { loadNodeHttpRequester } from '@agent-device/host-kit/transport'; import type { DaemonRequestMeta } from '@agent-device/kernel/contracts'; import { buildDaemonHttpAuthHeaders, + buildDaemonHttpLeaseHeaders, buildDaemonHttpTenantHeaders, } from '@agent-device/contracts/daemon-http'; @@ -89,6 +90,7 @@ export async function downloadRemoteArtifactFromUrl( headers: { ...buildDaemonHttpAuthHeaders(params.token), ...buildDaemonHttpTenantHeaders(params.requestScope?.tenantId), + ...buildDaemonHttpLeaseHeaders(params.requestScope?.leaseId), }, }, (res) => { diff --git a/src/remote/daemon-artifacts.ts b/src/remote/daemon-artifacts.ts index ad9f0f5fbe..1df8175aa9 100644 --- a/src/remote/daemon-artifacts.ts +++ b/src/remote/daemon-artifacts.ts @@ -5,6 +5,7 @@ import { buildRemoteTempArtifactPath, } from '@agent-device/contracts/daemon-http'; import { AppError } from '@agent-device/kernel/errors'; +import { leaseScopeFromRequest } from '@agent-device/contracts/lease-scope'; import type { DaemonArtifact, DaemonRequest, DaemonResponse } from '../daemon/daemon-request.ts'; import { appendRecordingExtensionWhenMissing, @@ -127,6 +128,7 @@ async function prepareRemoteInstallPackage( baseUrl: info.baseUrl!, token: info.token, platform: req.flags?.platform, + leaseId: leaseScopeFromRequest(req).leaseId, onProgress, signal, }); @@ -226,6 +228,7 @@ async function prepareRemoteInstallSource( baseUrl: info.baseUrl!, token: info.token, platform: req.flags?.platform, + leaseId: leaseScopeFromRequest(req).leaseId, onProgress, signal, }); diff --git a/src/remote/remote-request-diagnostics.ts b/src/remote/remote-request-diagnostics.ts index e257b9f89f..0bdefc0de1 100644 --- a/src/remote/remote-request-diagnostics.ts +++ b/src/remote/remote-request-diagnostics.ts @@ -23,6 +23,7 @@ import type { DaemonError, DiagnosticsRecordRef } from '@agent-device/kernel/err import { loadNodeHttpRequester } from '@agent-device/host-kit/transport'; import { buildDaemonHttpAuthHeaders, + buildDaemonHttpLeaseHeaders, buildDaemonHttpTenantHeaders, buildDaemonHttpUrl, } from '@agent-device/contracts/daemon-http'; @@ -35,6 +36,7 @@ export type RemoteDiagnosticsEndpoint = { baseUrl: string; token: string; tenantId?: string; + leaseId?: string; }; /** @@ -170,6 +172,7 @@ async function fetchRemoteRequestDiagnostics(params: { headers: { ...buildDaemonHttpAuthHeaders(endpoint.token), ...buildDaemonHttpTenantHeaders(endpoint.tenantId), + ...buildDaemonHttpLeaseHeaders(endpoint.leaseId), }, }, (res) => { diff --git a/src/remote/upload-client.ts b/src/remote/upload-client.ts index c0a1be29b1..d503c4aa07 100644 --- a/src/remote/upload-client.ts +++ b/src/remote/upload-client.ts @@ -1,6 +1,9 @@ import { randomUUID } from 'node:crypto'; import { AppError } from '@agent-device/kernel/errors'; -import { buildDaemonHttpAuthHeaders } from '@agent-device/contracts/daemon-http'; +import { + buildDaemonHttpAuthHeaders, + buildDaemonHttpLeaseHeaders, +} from '@agent-device/contracts/daemon-http'; import { prepareUploadArtifact, type PreparedUploadArtifact } from './upload-client-artifact.ts'; import { isRetryableUploadStreamError, streamFileToHttpRequest } from './upload-stream.ts'; import type { UploadProgressSink } from './upload-progress.ts'; @@ -13,6 +16,8 @@ type UploadArtifactOptions = { baseUrl: string; token: string; platform?: string; + /** The lease this upload is for; every request to the daemon names it in a header. */ + leaseId?: string; onProgress?: UploadProgressSink; /** * Ends the upload when the thing it is uploading for stops being worth finishing — today, a beat @@ -57,6 +62,7 @@ export async function uploadArtifact(options: UploadArtifactOptions): Promise; uploadAttemptId: string; @@ -115,10 +125,11 @@ async function tryDirectUploadWithResume(options: { const uploadOnce = async ( preflight: Extract, ): Promise => { - await uploadDirectArtifact(options.artifact, preflight, options.onProgress, options.signal); + await uploadDirectArtifact(options, preflight); return await finalizeDirectUpload({ normalizedBase: options.normalizedBase, token: options.token, + leaseId: options.leaseId, uploadId: preflight.uploadId, signal: options.signal, }); @@ -146,6 +157,7 @@ async function retryDirectUpload( options: { normalizedBase: string; token: string; + leaseId?: string; artifact: PreparedUploadArtifact; uploadAttemptId: string; onProgress?: UploadProgressSink; @@ -158,6 +170,7 @@ async function retryDirectUpload( const retryPreflight = await requestUploadPreflight({ normalizedBase: options.normalizedBase, token: options.token, + leaseId: options.leaseId, artifact: options.artifact, uploadAttemptId: options.uploadAttemptId, signal: options.signal, @@ -183,11 +196,12 @@ function shouldRetryDirectUpload(error: unknown): boolean { async function uploadLegacyArtifact(options: { normalizedBase: string; token: string; + leaseId?: string; artifact: PreparedUploadArtifact; onProgress?: UploadProgressSink; signal?: AbortSignal; }): Promise { - const { normalizedBase, token, artifact } = options; + const { normalizedBase, token, leaseId, artifact } = options; const uploadUrl = new URL('upload', normalizedBase); const headers: Record = { @@ -198,7 +212,7 @@ async function uploadLegacyArtifact(options: { 'x-artifact-hash-algorithm': ARTIFACT_HASH_ALGORITHM, 'transfer-encoding': 'chunked', }; - Object.assign(headers, buildDaemonHttpAuthHeaders(token)); + Object.assign(headers, buildDaemonHttpAuthHeaders(token), buildDaemonHttpLeaseHeaders(leaseId)); const response = await streamFileToHttpRequest({ url: uploadUrl, @@ -232,6 +246,7 @@ async function uploadLegacyArtifact(options: { async function requestUploadPreflight(options: { normalizedBase: string; token: string; + leaseId?: string; artifact: PreparedUploadArtifact; uploadAttemptId: string; signal?: AbortSignal; @@ -240,7 +255,11 @@ async function requestUploadPreflight(options: { const headers: Record = { 'content-type': 'application/json', }; - Object.assign(headers, buildDaemonHttpAuthHeaders(options.token)); + Object.assign( + headers, + buildDaemonHttpAuthHeaders(options.token), + buildDaemonHttpLeaseHeaders(options.leaseId), + ); const response = await fetch(preflightUrl, { method: 'POST', @@ -317,15 +336,26 @@ function isStringRecord(value: unknown): value is Record { } async function uploadDirectArtifact( - artifact: PreparedUploadArtifact, + options: { + normalizedBase: string; + leaseId?: string; + artifact: PreparedUploadArtifact; + onProgress?: UploadProgressSink; + signal?: AbortSignal; + }, ticket: Extract, - onProgress: UploadProgressSink | undefined, - signal: AbortSignal | undefined, ): Promise { + const { artifact, onProgress, signal } = options; + const url = new URL(ticket.url); const response = await streamFileToHttpRequest({ - url: new URL(ticket.url), + url, method: 'PUT', - headers: ticket.headers, + // A ticket on the daemon's own origin is routed like any daemon request, so it names the lease + // too; a ticket elsewhere (a signed storage URL) gets only the headers the daemon handed out. + headers: + url.origin === new URL(options.normalizedBase).origin + ? { ...ticket.headers, ...buildDaemonHttpLeaseHeaders(options.leaseId) } + : ticket.headers, payloadPath: artifact.payloadPath, timeoutMessage: 'Direct artifact upload timed out', timeoutHint: 'The direct upload ticket did not accept the artifact within the timeout.', @@ -350,6 +380,7 @@ async function uploadDirectArtifact( async function finalizeDirectUpload(options: { normalizedBase: string; token: string; + leaseId?: string; uploadId: string; signal?: AbortSignal; }): Promise { @@ -357,7 +388,11 @@ async function finalizeDirectUpload(options: { const headers: Record = { 'content-type': 'application/json', }; - Object.assign(headers, buildDaemonHttpAuthHeaders(options.token)); + Object.assign( + headers, + buildDaemonHttpAuthHeaders(options.token), + buildDaemonHttpLeaseHeaders(options.leaseId), + ); const response = await fetch(finalizeUrl, { method: 'POST', diff --git a/test/wire-compat/ledger.json b/test/wire-compat/ledger.json index 8e8077de80..10fa7af962 100644 --- a/test/wire-compat/ledger.json +++ b/test/wire-compat/ledger.json @@ -62,7 +62,7 @@ "src/daemon-client/daemon-client-rpc.ts#isLeaseRpcCommand": "sha256:955dc1b73462d40593e5728dbcbaeec1307944b08262a22627e84cc5dc507e8f", "src/daemon-client/daemon-client-rpc.ts#leaseRpcMethodForCommand": "sha256:3613555c6002c23ab5bec4b98bc1b4d6947014b52fa100176177f955cf762881", "src/daemon-client/daemon-client-rpc.ts#parseDaemonHttpResponseBody": "sha256:0cbe39dbd09e321ec00854a1626a7d89b6344e59c6faabe2fe02504268b2d886", - "src/daemon-client/daemon-client-rpc.ts#rejectDaemonHttpRpcError": "sha256:83b0312fe88bc3b3497de799e23d8d14455f665b7cf880f4617cd62b181351cd", + "src/daemon-client/daemon-client-rpc.ts#rejectDaemonHttpRpcError": "sha256:1272b06f13da00e05923cbb1aed15ab8fdf96e9caeb697fac52f78b0a2ba69e1", "src/daemon-client/daemon-client-rpc.ts#resolveDaemonHttpResult": "sha256:296f9d376ce67c8cb20209bdf1c59c2423ffcfa35b5565a99e70271263149048", "src/daemon-client/daemon-client-rpc.ts#toDaemonHttpRpcError": "sha256:888246763c48670e7da893054d025744654f8715c3b4906312617a2b5028316b", "src/daemon-client/daemon-client-transport.ts#RemoteDaemonHealth": "sha256:3bac36fa97090b273afe1128103e1bf476d05441fd9de41317f1b78775b88304", @@ -145,7 +145,7 @@ "src/daemon/upload-http.ts#sendJson": "sha256:c2317b4b14a10731684c9160038005965856150f05f8923765a3fa833d2ecae9", "src/daemon/upload-http.ts#sendUploadedArtifactResponse": "sha256:9e418403448d58a96284fed605762fa71f9349bf2c70ad79df69b2b3e27487d6", "src/remote/artifact-download.ts#RemoteArtifactDownload": "sha256:f73a888f4f8c9d603701c505bb342787b1ca6559a13b15da2385e60349ecea8e", - "src/remote/artifact-download.ts#downloadRemoteArtifactFromUrl": "sha256:5176697267907e3f5e4383a881969721e4ea3f6776128d28c313bdc210128834", + "src/remote/artifact-download.ts#downloadRemoteArtifactFromUrl": "sha256:cde1ed3649d73da51a0413216aa24a561a2ec57d08ed533ffa985b95b1941cf6", "src/remote/daemon-artifacts.ts#DaemonArtifactEndpoint": "sha256:45226717ac52c0af2a62f74e91549582d95f4a4abc49be87989e026291a88c82", "src/remote/daemon-artifacts.ts#DownloadRemoteArtifactParams": "sha256:3ec63be2dbdb542e30b19d1500bc16874607823afb7ae392e92b0b535865859e", "src/remote/daemon-artifacts.ts#buildDaemonArtifactUrl": "sha256:06dcde057809b507f70f7bb5cdef9bde6515be84b67e45865cf19e70b8636208", @@ -154,23 +154,23 @@ "src/remote/daemon-artifacts.ts#materializeRemoteArtifacts": "sha256:6a62429723f52231951ef263950f4b304529bad984ab216b73db1750f58c7f84", "src/remote/daemon-artifacts.ts#resolveMaterializedArtifactPath": "sha256:db5c44effc41ff9fa40bd094945e56ac60fac89a42fa68c65c9293e6de6fb1dc", "src/remote/remote-request-diagnostics.ts#RemoteDaemonErrorPayload": "sha256:8fbcbcdf4bae7b66a08e465a72593794f36364759f18f1a814cd39dfdb76f309", - "src/remote/remote-request-diagnostics.ts#RemoteDiagnosticsEndpoint": "sha256:bdf63219ecea77d1290f0f5eeef6d5adc55077fa2fa6750ba4608aa3efd540f0", + "src/remote/remote-request-diagnostics.ts#RemoteDiagnosticsEndpoint": "sha256:c1d5fe2fe90c425502cf8b7c87d6606374d6af65b4e9faf942a416f683930bdc", "src/remote/remote-request-diagnostics.ts#buildRemoteRequestDiagnosticsUrl": "sha256:30a1a9bf0df24d3564a616699916a3bb4360c2b4d21c4ad0562fb0c1e738d0a9", - "src/remote/remote-request-diagnostics.ts#fetchRemoteRequestDiagnostics": "sha256:d856cc83f831491a4c34b1e4feb0867741310bc435808dc469800a643c6b5c31", + "src/remote/remote-request-diagnostics.ts#fetchRemoteRequestDiagnostics": "sha256:6f68c317e4c0fe5de008ec1fe5e7e030a9d298ad4a7dfd561db933d22e12ae9e", "src/remote/remote-request-diagnostics.ts#localizeRemoteDaemonError": "sha256:35cfa186ad64663ae4dbb4e102d49238532cd7e3b75e8337edcc3d1fd77f92d5", "src/remote/upload-client-artifact.ts#PreparedUploadArtifact": "sha256:5c40027d47feda3c946e836372b9f08ad866ab1612708b0d506551545f931ea4", "src/remote/upload-client.ts#ARTIFACT_HASH_ALGORITHM": "sha256:328c135ea5f9d89fd2c142b440d5402a61d19f9044bf0504dfc2c525e58c1909", "src/remote/upload-client.ts#UploadPreflightResponse": "sha256:196a1d9ef41a1aec192589fa7ccb30cf71d971a005349d5d73eed42e6ab21327", "src/remote/upload-client.ts#UploadPreflightResult": "sha256:bd8fc6d617c0630325455fd64d0fe319daaced198ee6745e4673a8ace3df460d", "src/remote/upload-client.ts#UploadResponse": "sha256:499e01466b5e0e03413f8d3f1986f5af6bc120704c50a3747d84de422b3f0314", - "src/remote/upload-client.ts#finalizeDirectUpload": "sha256:5d283353df5f089a6ab26b7be96c44997e6b9905b329cb993d300c615479d715", + "src/remote/upload-client.ts#finalizeDirectUpload": "sha256:61f2811d571e57dcca41689c7b1dd37db1fd7a982fbf5eaf33a33cf1541f6ee4", "src/remote/upload-client.ts#isStringRecord": "sha256:b6d6b571b90baf0976d9c347c4a45a4363a4df5ad184ea1087cb3b9febd4b624", "src/remote/upload-client.ts#parseUploadPreflightResult": "sha256:7ce3f43b45c03df8b7f2690432beedc7a537c1bba540961716862f8a632d5623", - "src/remote/upload-client.ts#requestUploadPreflight": "sha256:4c9863bce8757f1dc0f01ec7374019e752efc8e786cd070200f516a1d72d09e1", + "src/remote/upload-client.ts#requestUploadPreflight": "sha256:b8caf697f613d9b7e6176007d9cd7f367519338a76f50c1107687bd800ff0a5a", "src/remote/upload-client.ts#shouldRetryDirectUpload": "sha256:29059f076ff7f2b9540ad15408be48792b2d63a601ef2b9fb2d54f5576d49bbd", - "src/remote/upload-client.ts#tryDirectUploadWithResume": "sha256:67596a2ff2b8a5402566c3b7b99a6fa1baaf9d5de3d647bf35398185cc45eea6", - "src/remote/upload-client.ts#uploadDirectArtifact": "sha256:c16cb5dd74895931b8e41e16629a0519e5601340563c976b7f800c6de8d158b2", - "src/remote/upload-client.ts#uploadLegacyArtifact": "sha256:a7a82d6eb2925d6f510b9cc00f01f9ce865638368e0bec503b700f0bc59871bc", + "src/remote/upload-client.ts#tryDirectUploadWithResume": "sha256:db1042b6b8f6969add9480f30a8649d83eecf60af38e80f00a4e789fcf925f97", + "src/remote/upload-client.ts#uploadDirectArtifact": "sha256:3edb622eabee720085a544a4d4e6fdd74c19ec25d3e14e7f9f06ea10d550c1c7", + "src/remote/upload-client.ts#uploadLegacyArtifact": "sha256:3acabacbdea5b7da7f3d8e6e87496728eb3b1df122c490751a9edabc1e9c9adc", "src/remote/upload-stream.ts#MAX_UPLOAD_REDIRECTS": "sha256:12ff46dfe33a1dce95bdb79c90c17a3ba4f3919e7dc4b3a1b04590fff430f79f", "src/remote/upload-stream.ts#UploadStreamResponse": "sha256:724e284c2a6bd7b4b3a49b1d8c61c8923d6cc24777c3d76224a69d190e56adce", "src/remote/upload-stream.ts#buildUploadRequestHeaders": "sha256:827576b13a9f3e6cea445119a6a29b2ac5eaa23b66436cf810b2d09765c82a4c", @@ -314,31 +314,6 @@ "digest": "sha256:7be778b3902c72d7aa6282c16dc7106fdd64e99c0b5daf9f413ff5dd3fb044f1", "rationale": "#2894 lets an iOS runner start outlive the caller whose deadline gave up on it, so a progress event can arrive after the response ended. The sink now also skips a response whose writable side has ended, next to the destroyed check it already had. No byte written to a live response changes: the envelope framing and payload are untouched, and a released client never sees an event it could not have received before." }, - { - "declaration": "src/remote/upload-client.ts#requestUploadPreflight", - "digest": "sha256:4c9863bce8757f1dc0f01ec7374019e752efc8e786cd070200f516a1d72d09e1", - "rationale": "#2946 runs the preflight fetch under the caller's abort signal in addition to its own timeout. Route, headers, and JSON body are unchanged, so a protocol-2 daemon parses an uncancelled preflight exactly as before; an aborted one is the client disconnect it already handles." - }, - { - "declaration": "src/remote/upload-client.ts#uploadDirectArtifact", - "digest": "sha256:c16cb5dd74895931b8e41e16629a0519e5601340563c976b7f800c6de8d158b2", - "rationale": "#2946 threads an optional abort signal into the PUT so a lost lease can stop the stream. Method, ticket url and headers, and body bytes are unchanged for an uncancelled upload; an abort truncates the request, which a protocol-2 daemon sees as the client disconnect it already handles." - }, - { - "declaration": "src/remote/upload-client.ts#tryDirectUploadWithResume", - "digest": "sha256:67596a2ff2b8a5402566c3b7b99a6fa1baaf9d5de3d647bf35398185cc45eea6", - "rationale": "#2946 stops a canceled upload from re-preflighting and lifts the retry leg into its own function. The request sequence a daemon sees — preflight, direct PUT, finalize, or the legacy fallback after a retryable failure — is unchanged." - }, - { - "declaration": "src/remote/upload-client.ts#finalizeDirectUpload", - "digest": "sha256:5d283353df5f089a6ab26b7be96c44997e6b9905b329cb993d300c615479d715", - "rationale": "#2946 adds the caller's abort signal to finalize's existing timeout and lets a canceled one reject with the signal's own reason instead of a wrapped transport error. Route and body keys are unchanged, so a protocol-2 daemon parses an uncancelled finalize exactly as before." - }, - { - "declaration": "src/remote/upload-client.ts#uploadLegacyArtifact", - "digest": "sha256:a7a82d6eb2925d6f510b9cc00f01f9ce865638368e0bec503b700f0bc59871bc", - "rationale": "#2946 threads an optional abort signal into the legacy upload stream. Method, route, artifact headers, and chunked body are unchanged for an uncancelled upload; an abort truncates the request, which a protocol-2 daemon sees as the client disconnect it already handles." - }, { "declaration": "src/remote/upload-stream.ts#streamFileToHttpRequest", "digest": "sha256:ce07ea33a275e06e4cceaf0c75a079bd0c7938f36df6817407dbb1cc8c4ff900", @@ -433,6 +408,51 @@ "declaration": "src/daemon-client/daemon-client-transport.ts#readDaemonHttpHealth", "digest": "sha256:6ce1f9be1e6a977130d0875003ac22da2de5e90b514a3b22b30b9347e63d81c7", "rationale": "The health-probe budget includes requester loading and response completion. The caller AbortSignal joins the probe timer only in-process; caller cancellation remains distinct from probe expiry. No signal, timedOut flag, request route, auth header or accepted health payload field changes on the wire, so released daemons and proxies parse and respond as before (ADR 0006)." + }, + { + "declaration": "src/remote/remote-request-diagnostics.ts#RemoteDiagnosticsEndpoint", + "digest": "sha256:c1d5fe2fe90c425502cf8b7c87d6606374d6af65b4e9faf942a416f683930bdc", + "rationale": "Clients name a held agent-device lease in an optional x-agent-device-lease-id request header on upload, artifact download and request diagnostics HTTP calls, so a gateway can route them. Released daemons ignore unknown request headers and the proxy forwards only its allowlist, so routes, bodies and responses parse as before; with no lease held the request is unchanged (ADR 0006)." + }, + { + "declaration": "src/remote/remote-request-diagnostics.ts#fetchRemoteRequestDiagnostics", + "digest": "sha256:6f68c317e4c0fe5de008ec1fe5e7e030a9d298ad4a7dfd561db933d22e12ae9e", + "rationale": "Clients name a held agent-device lease in an optional x-agent-device-lease-id request header on upload, artifact download and request diagnostics HTTP calls, so a gateway can route them. Released daemons ignore unknown request headers and the proxy forwards only its allowlist, so routes, bodies and responses parse as before; with no lease held the request is unchanged (ADR 0006)." + }, + { + "declaration": "src/daemon-client/daemon-client-rpc.ts#rejectDaemonHttpRpcError", + "digest": "sha256:1272b06f13da00e05923cbb1aed15ab8fdf96e9caeb697fac52f78b0a2ba69e1", + "rationale": "Clients name a held agent-device lease in an optional x-agent-device-lease-id request header on upload, artifact download and request diagnostics HTTP calls, so a gateway can route them. Released daemons ignore unknown request headers and the proxy forwards only its allowlist, so routes, bodies and responses parse as before; with no lease held the request is unchanged (ADR 0006)." + }, + { + "declaration": "src/remote/upload-client.ts#requestUploadPreflight", + "digest": "sha256:b8caf697f613d9b7e6176007d9cd7f367519338a76f50c1107687bd800ff0a5a", + "rationale": "Clients name a held agent-device lease in an optional x-agent-device-lease-id request header on upload, artifact download and request diagnostics HTTP calls, so a gateway can route them. Released daemons ignore unknown request headers and the proxy forwards only its allowlist, so routes, bodies and responses parse as before; with no lease held the request is unchanged (ADR 0006)." + }, + { + "declaration": "src/remote/upload-client.ts#uploadDirectArtifact", + "digest": "sha256:3edb622eabee720085a544a4d4e6fdd74c19ec25d3e14e7f9f06ea10d550c1c7", + "rationale": "Clients name a held agent-device lease in an optional x-agent-device-lease-id request header on upload, artifact download and request diagnostics HTTP calls, so a gateway can route them. Released daemons ignore unknown request headers and the proxy forwards only its allowlist, so routes, bodies and responses parse as before; with no lease held the request is unchanged (ADR 0006)." + }, + { + "declaration": "src/remote/upload-client.ts#tryDirectUploadWithResume", + "digest": "sha256:db1042b6b8f6969add9480f30a8649d83eecf60af38e80f00a4e789fcf925f97", + "rationale": "Clients name a held agent-device lease in an optional x-agent-device-lease-id request header on upload, artifact download and request diagnostics HTTP calls, so a gateway can route them. Released daemons ignore unknown request headers and the proxy forwards only its allowlist, so routes, bodies and responses parse as before; with no lease held the request is unchanged (ADR 0006)." + }, + { + "declaration": "src/remote/upload-client.ts#finalizeDirectUpload", + "digest": "sha256:61f2811d571e57dcca41689c7b1dd37db1fd7a982fbf5eaf33a33cf1541f6ee4", + "rationale": "Clients name a held agent-device lease in an optional x-agent-device-lease-id request header on upload, artifact download and request diagnostics HTTP calls, so a gateway can route them. Released daemons ignore unknown request headers and the proxy forwards only its allowlist, so routes, bodies and responses parse as before; with no lease held the request is unchanged (ADR 0006)." + }, + { + "declaration": "src/remote/upload-client.ts#uploadLegacyArtifact", + "digest": "sha256:3acabacbdea5b7da7f3d8e6e87496728eb3b1df122c490751a9edabc1e9c9adc", + "rationale": "Clients name a held agent-device lease in an optional x-agent-device-lease-id request header on upload, artifact download and request diagnostics HTTP calls, so a gateway can route them. Released daemons ignore unknown request headers and the proxy forwards only its allowlist, so routes, bodies and responses parse as before; with no lease held the request is unchanged (ADR 0006)." + }, + { + "declaration": "src/remote/artifact-download.ts#downloadRemoteArtifactFromUrl", + "digest": "sha256:cde1ed3649d73da51a0413216aa24a561a2ec57d08ed533ffa985b95b1941cf6", + "rationale": "Clients name a held agent-device lease in an optional x-agent-device-lease-id request header on upload, artifact download and request diagnostics HTTP calls, so a gateway can route them. Released daemons ignore unknown request headers and the proxy forwards only its allowlist, so routes, bodies and responses parse as before; with no lease held the request is unchanged (ADR 0006)." } ] }