Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions packages/contracts/src/daemon-http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
buildDaemonHealthPayload,
buildDaemonHttpAuthHeaders,
buildDaemonHttpBaseUrl,
buildDaemonHttpLeaseHeaders,
buildDaemonHttpTenantHeaders,
buildDaemonHttpUrl,
buildRemoteTempArtifactDirPath,
Expand Down Expand Up @@ -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,
Expand Down
14 changes: 12 additions & 2 deletions packages/contracts/src/daemon-http.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -35,6 +39,12 @@ export function buildDaemonHttpTenantHeaders(tenantId: string | undefined): Reco
return { [DAEMON_HTTP_TENANT_HEADER]: normalizedTenantId };
}

export function buildDaemonHttpLeaseHeaders(leaseId: string | undefined): Record<string, string> {
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
Expand Down
146 changes: 146 additions & 0 deletions src/__tests__/upload-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
leaseHeaders: Map<string, unknown>;
}> {
const leaseHeaders = new Map<string, unknown>();
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);
Expand Down
Loading
Loading