Skip to content

Commit be15e94

Browse files
committed
fix(remote): preserve tenant scope for proxy artifact downloads
1 parent 8eeed1d commit be15e94

6 files changed

Lines changed: 141 additions & 3 deletions

File tree

src/daemon/__tests__/http-contract.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
33
import {
44
buildDaemonHttpAuthHeaders,
55
buildDaemonHttpBaseUrl,
6+
buildDaemonHttpTenantHeaders,
67
buildDaemonHttpUrl,
78
} from '../http-contract.ts';
89

@@ -35,3 +36,10 @@ test('buildDaemonHttpAuthHeaders writes both supported daemon auth headers', ()
3536
});
3637
assert.deepEqual(buildDaemonHttpAuthHeaders(''), {});
3738
});
39+
40+
test('buildDaemonHttpTenantHeaders omits blank tenant identities', () => {
41+
assert.deepEqual(buildDaemonHttpTenantHeaders(' tenant-a '), {
42+
'x-agent-device-tenant': 'tenant-a',
43+
});
44+
assert.deepEqual(buildDaemonHttpTenantHeaders(''), {});
45+
});

src/daemon/http-contract.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export const DAEMON_HTTP_BASE_PATH = '/agent-device';
2+
export const DAEMON_HTTP_TENANT_HEADER = 'x-agent-device-tenant';
23

34
export function buildDaemonHttpBaseUrl(baseUrl: string): string {
45
return buildDaemonHttpUrl(baseUrl, DAEMON_HTTP_BASE_PATH);
@@ -17,3 +18,9 @@ export function buildDaemonHttpAuthHeaders(token: string | undefined): Record<st
1718
'x-agent-device-token': normalizedToken,
1819
};
1920
}
21+
22+
export function buildDaemonHttpTenantHeaders(tenantId: string | undefined): Record<string, string> {
23+
const normalizedTenantId = tenantId?.trim();
24+
if (!normalizedTenantId) return {};
25+
return { [DAEMON_HTTP_TENANT_HEADER]: normalizedTenantId };
26+
}

src/daemon/server/http-server.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
shouldStreamRequestProgress,
2727
} from '../request-progress-protocol.ts';
2828
import { buildDaemonHealthPayload } from '../http-health.ts';
29+
import { DAEMON_HTTP_TENANT_HEADER } from '../http-contract.ts';
2930
import { sendRestJsonError, statusCodeForNormalizedError } from '../http-errors.ts';
3031
import { tryHandleUploadHttpRoute } from '../upload-http.ts';
3132
import { tryHandleDownloadableArtifactHttpRoute } from '../downloadable-artifact-http.ts';
@@ -753,6 +754,7 @@ async function authorizeAuxiliaryHttpRequest(params: {
753754
}): Promise<{ tenantId?: string } | null> {
754755
const { req, res, authHook, expectedToken, daemonRequest } = params;
755756
const token = resolveToken({}, req.headers);
757+
const tenantId = normalizeTenantId(readHeaderValue(req.headers, DAEMON_HTTP_TENANT_HEADER));
756758
const tokenError = enforceDaemonToken(token, expectedToken);
757759
if (tokenError) {
758760
sendRestJsonError(res, tokenError);
@@ -772,6 +774,7 @@ async function authorizeAuxiliaryHttpRequest(params: {
772774
session: 'default',
773775
command: daemonRequest.command,
774776
positionals: daemonRequest.positionals,
777+
...(tenantId ? { meta: { tenantId } } : {}),
775778
},
776779
});
777780
if (!authResult.ok) {
@@ -789,7 +792,12 @@ async function authorizeAuxiliaryHttpRequest(params: {
789792
return null;
790793
}
791794

792-
return { tenantId: authResult.tenantId };
795+
return { tenantId: authResult.tenantId ?? tenantId };
796+
}
797+
798+
function readHeaderValue(headers: IncomingHttpHeaders, name: string): string | undefined {
799+
const value = headers[name];
800+
return typeof value === 'string' ? value : undefined;
793801
}
794802

795803
function enforceDaemonToken(

src/remote/daemon-artifacts.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ import path from 'node:path';
55
import { pipeline } from 'node:stream/promises';
66
import { AppError } from '../kernel/errors.ts';
77
import type { DaemonArtifact, DaemonRequest, DaemonResponse } from '../daemon/types.ts';
8-
import { buildDaemonHttpAuthHeaders } from '../daemon/http-contract.ts';
8+
import {
9+
buildDaemonHttpAuthHeaders,
10+
buildDaemonHttpTenantHeaders,
11+
} from '../daemon/http-contract.ts';
912
import {
1013
appendRecordingExtensionWhenMissing,
1114
recordingExtensionForPlatform,
@@ -312,6 +315,7 @@ export async function materializeRemoteArtifacts(
312315
artifactId: artifact.artifactId,
313316
destinationPath: localPath,
314317
requestId: req.meta?.requestId,
318+
tenantId: req.meta?.tenantId,
315319
});
316320
nextData[artifact.field] = localPath;
317321
nextArtifacts.push({
@@ -341,6 +345,7 @@ type DownloadRemoteArtifactParams = {
341345
artifactId: string;
342346
destinationPath: string;
343347
requestId?: string;
348+
tenantId?: string;
344349
timeoutMs?: number;
345350
};
346351

@@ -368,7 +373,10 @@ export async function downloadRemoteArtifact(params: DownloadRemoteArtifactParam
368373
port: artifactUrl.port,
369374
method: 'GET',
370375
path: artifactUrl.pathname + artifactUrl.search,
371-
headers: buildDaemonHttpAuthHeaders(params.token),
376+
headers: {
377+
...buildDaemonHttpAuthHeaders(params.token),
378+
...buildDaemonHttpTenantHeaders(params.tenantId),
379+
},
372380
},
373381
(res) => {
374382
if ((res.statusCode ?? 500) >= 400) {

src/remote/daemon-proxy.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { readNodeHttpRequestBody } from '../utils/node-http.ts';
77
import { timingSafeStringEqual } from '../utils/timing-safe-equal.ts';
88
import {
99
DAEMON_HTTP_BASE_PATH,
10+
DAEMON_HTTP_TENANT_HEADER,
1011
buildDaemonHttpAuthHeaders,
1112
buildDaemonHttpUrl,
1213
} from '../daemon/http-contract.ts';
@@ -31,6 +32,7 @@ const FORWARDED_REQUEST_HEADERS = [
3132
'x-artifact-filename',
3233
'x-artifact-hash',
3334
'x-artifact-hash-algorithm',
35+
DAEMON_HTTP_TENANT_HEADER,
3436
];
3537
const FORWARDED_RESPONSE_HEADERS = ['content-type', 'content-disposition', 'x-request-id'];
3638

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import assert from 'node:assert/strict';
2+
import fs from 'node:fs';
3+
import os from 'node:os';
4+
import path from 'node:path';
5+
import { test } from 'vitest';
6+
import { createAgentDeviceClient } from '../../../src/agent-device-client.ts';
7+
import {
8+
cleanupDownloadableArtifact,
9+
trackDownloadableArtifact,
10+
} from '../../../src/daemon/artifact-tracking.ts';
11+
import { finalizeDaemonResponse } from '../../../src/daemon/request-finalization.ts';
12+
import { createDaemonHttpServer } from '../../../src/daemon/server/http-server.ts';
13+
import { normalizeAgentDeviceError } from '../../../src/kernel/errors.ts';
14+
import { downloadRemoteArtifact } from '../../../src/remote/daemon-artifacts.ts';
15+
import { createDaemonProxyServer } from '../../../src/remote/daemon-proxy.ts';
16+
import {
17+
closeLoopbackServer,
18+
listenOnLoopback,
19+
skipWhenLoopbackUnavailable,
20+
} from '../../../src/__tests__/test-utils/loopback.ts';
21+
22+
const TENANT = 'local-proxy-tenant';
23+
const OTHER_TENANT = 'other-tenant';
24+
25+
test('Provider-backed integration local proxy materializes tenant-scoped screenshots', async (t) => {
26+
if (await skipWhenLoopbackUnavailable(t, 'local proxy artifact tenant coverage')) return;
27+
28+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-proxy-artifact-tenant-'));
29+
const remoteScreenshotPath = path.join(tempDir, 'remote-shot.png');
30+
const localScreenshotPath = path.join(tempDir, 'local-shot.png');
31+
const rejectedScreenshotPath = path.join(tempDir, 'rejected-shot.png');
32+
fs.writeFileSync(remoteScreenshotPath, 'tenant-scoped-png');
33+
const artifactIds: string[] = [];
34+
const upstream = await createDaemonHttpServer({
35+
token: 'upstream-token',
36+
handleRequest: async (req) => {
37+
assert.equal(req.command, 'screenshot');
38+
assert.equal(req.meta?.tenantId, TENANT);
39+
assert.equal(req.meta?.runId, 'local-proxy-run');
40+
assert.equal(req.meta?.sessionIsolation, 'tenant');
41+
return finalizeDaemonResponse(
42+
req,
43+
{ ok: true, data: { path: remoteScreenshotPath } },
44+
(artifact) => {
45+
const artifactId = trackDownloadableArtifact(artifact);
46+
artifactIds.push(artifactId);
47+
return artifactId;
48+
},
49+
);
50+
},
51+
});
52+
const protectedArtifactId = trackDownloadableArtifact({
53+
artifactPath: remoteScreenshotPath,
54+
tenantId: TENANT,
55+
artifactType: 'screenshot',
56+
fileName: 'remote-shot.png',
57+
});
58+
artifactIds.push(protectedArtifactId);
59+
const proxy = createDaemonProxyServer({
60+
upstreamBaseUrl: `http://127.0.0.1:${await listenOnLoopback(upstream)}`,
61+
upstreamToken: 'upstream-token',
62+
clientToken: 'proxy-token',
63+
});
64+
65+
try {
66+
const proxyPort = await listenOnLoopback(proxy);
67+
const daemonBaseUrl = `http://127.0.0.1:${proxyPort}/agent-device`;
68+
69+
await assert.rejects(
70+
async () =>
71+
await downloadRemoteArtifact({
72+
baseUrl: daemonBaseUrl,
73+
token: 'proxy-token',
74+
tenantId: OTHER_TENANT,
75+
artifactId: protectedArtifactId,
76+
destinationPath: rejectedScreenshotPath,
77+
}),
78+
(error: unknown) => {
79+
const normalized = normalizeAgentDeviceError(error);
80+
assert.equal(normalized.details?.statusCode, 401);
81+
assert.match(String(normalized.details?.body), /different tenant/i);
82+
return true;
83+
},
84+
);
85+
assert.equal(fs.existsSync(rejectedScreenshotPath), false);
86+
87+
const client = createAgentDeviceClient({
88+
daemonBaseUrl,
89+
daemonAuthToken: 'proxy-token',
90+
tenant: TENANT,
91+
runId: 'local-proxy-run',
92+
sessionIsolation: 'tenant',
93+
stateDir: tempDir,
94+
});
95+
const screenshot = await client.capture.screenshot({ path: localScreenshotPath });
96+
97+
assert.equal(screenshot.path, localScreenshotPath);
98+
assert.equal(fs.readFileSync(localScreenshotPath, 'utf8'), 'tenant-scoped-png');
99+
} finally {
100+
for (const artifactId of artifactIds) cleanupDownloadableArtifact(artifactId);
101+
await closeLoopbackServer(proxy);
102+
await closeLoopbackServer(upstream);
103+
fs.rmSync(tempDir, { recursive: true, force: true });
104+
}
105+
});

0 commit comments

Comments
 (0)