From d6ceddbf1a9c478df0507b56e255188047184ebe Mon Sep 17 00:00:00 2001 From: Khaliq Date: Mon, 17 Aug 2026 13:29:25 +0200 Subject: [PATCH 1/4] feat(auth): allow hosted Cloud token providers --- .../relayfile-cloud-mount-client.test.ts | 111 ++++++++++++++++++ src/mount/relayfile-cloud-mount-client.ts | 95 ++++++++++++++- 2 files changed, 202 insertions(+), 4 deletions(-) diff --git a/src/mount/relayfile-cloud-mount-client.test.ts b/src/mount/relayfile-cloud-mount-client.test.ts index 4dcfda8..a421ae3 100644 --- a/src/mount/relayfile-cloud-mount-client.test.ts +++ b/src/mount/relayfile-cloud-mount-client.test.ts @@ -12,6 +12,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { + FACTORY_CLOUD_ACCESS_TOKEN_URL_ENV, FACTORY_RELAYFILE_SCOPES, RelayfileCloudMountClient, relayfileWorkspaceTokenProvider, @@ -1005,6 +1006,116 @@ describe('RelayfileCloudMountClient', () => { await expect(capturedTokenProvider?.()).resolves.toBe('cld_at_shared') }) + it('uses an injected hosted token provider without consulting local Cloud login or AGENT_RELAY_BIN', async () => { + const fake = new FakeRelayFileClient() + const handle = { + client: vi.fn(() => fake), + getToken: vi.fn(async () => 'delegated-relayfile-token'), + info: { relayfileUrl: 'https://relayfile.example' }, + } + const setup = { joinWorkspace: vi.fn(async () => handle) } + const cloudSessionProvider = vi.fn(async () => { + throw new Error('local Cloud login must not be consulted') + }) + const cloudAccessTokenProvider = vi.fn(async () => 'relay_pa_hosted_access') + let capturedTokenProvider: (() => Promise) | undefined + const relayfileSetupFactory: RelayfileSetupFactory = vi.fn(({ tokenProvider }) => { + capturedTokenProvider = tokenProvider + return setup + }) + + const mount = await RelayfileCloudMountClient.fromConfig({ + workspaceId: 'rw_test', + cloudApiUrl: 'https://cloud.example', + cloudAccessTokenProvider, + cloudSessionProvider, + cloudSessionEnv: { AGENT_RELAY_BIN: '/definitely/not/a/node-cli' }, + relayfileSetupFactory, + }) + + expect(mount.workspaceId).toBe('rw_test') + expect(cloudSessionProvider).not.toHaveBeenCalled() + expect(relayfileSetupFactory).toHaveBeenCalledWith({ + cloudApiUrl: 'https://cloud.example', + tokenProvider: cloudAccessTokenProvider, + }) + expect(setup.joinWorkspace).toHaveBeenCalledWith('rw_test', { + agentName: 'agent-relay-factory', + scopes: [...FACTORY_RELAYFILE_SCOPES], + }) + await expect(capturedTokenProvider?.()).resolves.toBe('relay_pa_hosted_access') + }) + + it('fails loudly when a hosted token provider has no Cloud API URL', async () => { + await expect(RelayfileCloudMountClient.fromConfig({ + workspaceId: 'rw_test', + cloudAccessTokenProvider: async () => 'relay_pa_hosted_access', + })).rejects.toThrow('cloudApiUrl') + }) + + it('loads the hosted CLI credential through the private endpoint without local login or AGENT_RELAY_BIN', async () => { + const fake = new FakeRelayFileClient() + const cloudSessionProvider = vi.fn(async () => { + throw new Error('local Cloud login must not be consulted') + }) + const fetchImpl = vi.fn(async () => Response.json({ accessToken: 'relay_pa_rotated_access' })) + let capturedTokenProvider: (() => Promise) | undefined + const relayfileSetupFactory: RelayfileSetupFactory = vi.fn(({ tokenProvider }) => { + capturedTokenProvider = tokenProvider + return { + joinWorkspace: vi.fn(async () => ({ + client: () => fake, + getToken: async () => 'delegated-relayfile-token', + info: { relayfileUrl: 'https://relayfile.example' }, + })), + } + }) + + await RelayfileCloudMountClient.fromConfig({ + workspaceId: 'rw_test', + cloudSessionProvider, + cloudSessionEnv: { + [FACTORY_CLOUD_ACCESS_TOKEN_URL_ENV]: 'http://factory-auth.do/v1/access', + CLOUD_API_URL: 'https://cloud.example', + AGENT_RELAY_BIN: '/definitely/not/a/node-cli', + }, + cloudAccessTokenFetch: fetchImpl as unknown as typeof fetch, + relayfileSetupFactory, + }) + + expect(cloudSessionProvider).not.toHaveBeenCalled() + expect(relayfileSetupFactory).toHaveBeenCalledWith({ + cloudApiUrl: 'https://cloud.example', + tokenProvider: expect.any(Function), + }) + await expect(capturedTokenProvider?.()).resolves.toBe('relay_pa_rotated_access') + expect(fetchImpl).toHaveBeenCalledWith(new URL('http://factory-auth.do/v1/access'), expect.objectContaining({ + method: 'GET', + redirect: 'error', + headers: expect.objectContaining({ 'cache-control': 'no-store' }), + })) + }) + + it('fails hosted startup when the private endpoint does not return a RelayAuth path token', async () => { + const fetchImpl = vi.fn(async () => Response.json({ accessToken: 'relay_ws_overbroad' })) + const relayfileSetupFactory: RelayfileSetupFactory = ({ tokenProvider }) => ({ + joinWorkspace: async () => { + await tokenProvider() + throw new Error('unreachable') + }, + }) + + await expect(RelayfileCloudMountClient.fromConfig({ + workspaceId: 'rw_test', + cloudSessionEnv: { + [FACTORY_CLOUD_ACCESS_TOKEN_URL_ENV]: 'http://factory-auth.do/v1/access', + CLOUD_API_URL: 'https://cloud.example', + }, + cloudAccessTokenFetch: fetchImpl as unknown as typeof fetch, + relayfileSetupFactory, + })).rejects.toThrow('invalid token class') + }) + it('uses the shared cloud session provider for refreshed relayfile workspace token mints', async () => { let auth = storedAuth({ accessToken: 'cld_at_shared', refreshToken: 'cld_rt_shared' }) const setup = { diff --git a/src/mount/relayfile-cloud-mount-client.ts b/src/mount/relayfile-cloud-mount-client.ts index 2e04912..2c1f107 100644 --- a/src/mount/relayfile-cloud-mount-client.ts +++ b/src/mount/relayfile-cloud-mount-client.ts @@ -1,5 +1,6 @@ import { CloudAuthError, + defaultApiUrl, ensureCloudSession, resolveActiveWorkspace, type ActiveWorkspaceDescriptor, @@ -60,6 +61,8 @@ const DEFAULT_WORKSPACE_ID = 'rw_7ccfea89' const DEFAULT_AGENT_NAME = 'agent-relay-factory' const DEFAULT_LOCAL_MOUNT_HEALTH_INTERVAL_MS = 30_000 const DEFAULT_LOCAL_MOUNT_MAX_CONCURRENCY = 4 +const DEFAULT_HOSTED_ACCESS_TOKEN_TIMEOUT_MS = 10_000 +export const FACTORY_CLOUD_ACCESS_TOKEN_URL_ENV = 'FACTORY_CLOUD_ACCESS_TOKEN_URL' export const FACTORY_RELAYFILE_SCOPES = [ 'relayfile:fs:read:/linear/issues/**', 'relayfile:fs:read:/linear/states/**', @@ -190,6 +193,18 @@ export interface RelayfileCloudMountClientConfig { backend?: 'relayfile-cloud' workspaceId?: string cloudApiUrl?: string + /** + * Non-interactive host credential used only for the Cloud workspace join. + * Hosted runtimes inject a rotating, fixed RelayAuth path-token provider; + * when present, Factory never reads the local Cloud login or shells out. + */ + cloudAccessTokenProvider?: () => Promise + /** Private host endpoint that returns the current fixed RelayAuth access token. */ + cloudAccessTokenUrl?: string + /** Internal fetch override for hosted credential-provider tests. */ + cloudAccessTokenFetch?: typeof fetch + /** Internal hosted credential request timeout override. */ + cloudAccessTokenTimeoutMs?: number cloudSessionProvider?: CloudSessionProvider cloudSessionRefreshTimeoutMs?: number cloudSessionEnv?: NodeJS.ProcessEnv @@ -361,11 +376,32 @@ export class RelayfileCloudMountClient implements MountClient { if (config.client) return new RelayfileCloudMountClient(config) const workspaceId = config.workspaceId ?? DEFAULT_WORKSPACE_ID - const sharedSession = createSharedCloudSessionResolver(config) - const initialSession = await sharedSession.resolve() + const runtimeEnv = config.cloudSessionEnv ?? process.env + const hostedAccessTokenUrl = config.cloudAccessTokenUrl + ?? runtimeEnv[FACTORY_CLOUD_ACCESS_TOKEN_URL_ENV]?.trim() + const hostedTokenProvider = !config.cloudAccessTokenProvider && hostedAccessTokenUrl + ? createHostedCloudAccessTokenProvider({ + url: hostedAccessTokenUrl, + fetchImpl: config.cloudAccessTokenFetch ?? fetch, + timeoutMs: config.cloudAccessTokenTimeoutMs ?? DEFAULT_HOSTED_ACCESS_TOKEN_TIMEOUT_MS, + }) + : undefined + const directTokenProvider = config.cloudAccessTokenProvider ?? hostedTokenProvider + const sharedSession = directTokenProvider ? undefined : createSharedCloudSessionResolver(config) + const initialSession = sharedSession ? await sharedSession.resolve() : undefined + const cloudApiUrl = config.cloudApiUrl + ?? initialSession?.auth.apiUrl + ?? (hostedTokenProvider ? (runtimeEnv.CLOUD_API_URL?.trim() || defaultApiUrl()) : undefined) + if (!cloudApiUrl) { + throw new Error('Relayfile hosted access requires cloudApiUrl with cloudAccessTokenProvider') + } + const tokenProvider = directTokenProvider ?? sharedSession?.getAccessToken + if (!tokenProvider) { + throw new Error('Relayfile hosted access token provider is unavailable') + } const setup = (config.relayfileSetupFactory ?? createDefaultRelayfileSetup)({ - cloudApiUrl: initialSession.auth.apiUrl, - tokenProvider: sharedSession.getAccessToken, + cloudApiUrl, + tokenProvider, }) const handle = await setup.joinWorkspace(workspaceId, { agentName: config.agentName ?? DEFAULT_AGENT_NAME, @@ -952,6 +988,57 @@ const createDefaultRelayfileSetup: RelayfileSetupFactory = ({ cloudApiUrl, token accessToken: tokenProvider, }) as unknown as RelayfileSetupLike +const createHostedCloudAccessTokenProvider = (options: { + url: string + fetchImpl: typeof fetch + timeoutMs: number +}): (() => Promise) => { + let url: URL + try { + url = new URL(options.url) + } catch { + throw new Error(`${FACTORY_CLOUD_ACCESS_TOKEN_URL_ENV} must be an absolute URL`) + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error(`${FACTORY_CLOUD_ACCESS_TOKEN_URL_ENV} must use http or https`) + } + if (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0) { + throw new Error('hosted Cloud access-token timeout must be positive') + } + + return async (): Promise => { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), options.timeoutMs) + try { + const response = await options.fetchImpl(url, { + method: 'GET', + headers: { accept: 'application/json', 'cache-control': 'no-store' }, + redirect: 'error', + signal: controller.signal, + }) + if (!response.ok) { + throw new Error(`hosted Cloud access-token provider returned HTTP ${String(response.status)}`) + } + const payload = await response.json() as unknown + const accessToken = payload !== null && typeof payload === 'object' && !Array.isArray(payload) + && typeof (payload as { accessToken?: unknown }).accessToken === 'string' + ? (payload as { accessToken: string }).accessToken.trim() + : '' + if (!accessToken.startsWith('relay_pa_')) { + throw new Error('hosted Cloud access-token provider returned an invalid token class') + } + return accessToken + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw new Error(`hosted Cloud access-token provider timed out after ${String(options.timeoutMs)}ms`) + } + throw error + } finally { + clearTimeout(timer) + } + } +} + const createSharedCloudSessionResolver = (config: RelayfileCloudMountClientConfig): { resolve: () => Promise getAccessToken: () => Promise From 5645badd69325638a89b662f13963358999af728 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Mon, 17 Aug 2026 13:51:15 +0200 Subject: [PATCH 2/4] fix: fail closed on hosted credential errors --- .../relayfile-cloud-mount-client.test.ts | 39 +++++++++++++++++++ src/mount/relayfile-cloud-mount-client.ts | 28 ++++++++----- 2 files changed, 58 insertions(+), 9 deletions(-) diff --git a/src/mount/relayfile-cloud-mount-client.test.ts b/src/mount/relayfile-cloud-mount-client.test.ts index a421ae3..f17cbf8 100644 --- a/src/mount/relayfile-cloud-mount-client.test.ts +++ b/src/mount/relayfile-cloud-mount-client.test.ts @@ -1096,6 +1096,45 @@ describe('RelayfileCloudMountClient', () => { })) }) + it('fails closed when the hosted credential endpoint variable is defined but blank', async () => { + const cloudSessionProvider = vi.fn(async () => { + throw new Error('local Cloud login must not be consulted') + }) + + await expect(RelayfileCloudMountClient.fromConfig({ + workspaceId: 'rw_test', + cloudSessionProvider, + cloudSessionEnv: { + [FACTORY_CLOUD_ACCESS_TOKEN_URL_ENV]: ' ', + CLOUD_API_URL: 'https://cloud.example', + }, + })).rejects.toThrow(`${FACTORY_CLOUD_ACCESS_TOKEN_URL_ENV} must be an absolute URL`) + expect(cloudSessionProvider).not.toHaveBeenCalled() + }) + + it('cancels a failed hosted credential response body without reading it', async () => { + const response = new Response('sensitive failure details', { status: 503 }) + const cancel = vi.spyOn(response.body!, 'cancel') + const fetchImpl = vi.fn(async () => response) + const relayfileSetupFactory: RelayfileSetupFactory = ({ tokenProvider }) => ({ + joinWorkspace: async () => { + await tokenProvider() + throw new Error('unreachable') + }, + }) + + await expect(RelayfileCloudMountClient.fromConfig({ + workspaceId: 'rw_test', + cloudSessionEnv: { + [FACTORY_CLOUD_ACCESS_TOKEN_URL_ENV]: 'http://factory-auth.do/v1/access', + CLOUD_API_URL: 'https://cloud.example', + }, + cloudAccessTokenFetch: fetchImpl as unknown as typeof fetch, + relayfileSetupFactory, + })).rejects.toThrow('returned HTTP 503') + expect(cancel).toHaveBeenCalledOnce() + }) + it('fails hosted startup when the private endpoint does not return a RelayAuth path token', async () => { const fetchImpl = vi.fn(async () => Response.json({ accessToken: 'relay_ws_overbroad' })) const relayfileSetupFactory: RelayfileSetupFactory = ({ tokenProvider }) => ({ diff --git a/src/mount/relayfile-cloud-mount-client.ts b/src/mount/relayfile-cloud-mount-client.ts index 2c1f107..94eaf77 100644 --- a/src/mount/relayfile-cloud-mount-client.ts +++ b/src/mount/relayfile-cloud-mount-client.ts @@ -378,27 +378,32 @@ export class RelayfileCloudMountClient implements MountClient { const workspaceId = config.workspaceId ?? DEFAULT_WORKSPACE_ID const runtimeEnv = config.cloudSessionEnv ?? process.env const hostedAccessTokenUrl = config.cloudAccessTokenUrl - ?? runtimeEnv[FACTORY_CLOUD_ACCESS_TOKEN_URL_ENV]?.trim() - const hostedTokenProvider = !config.cloudAccessTokenProvider && hostedAccessTokenUrl + ?? runtimeEnv[FACTORY_CLOUD_ACCESS_TOKEN_URL_ENV] + const hasHostedAccessTokenConfig = config.cloudAccessTokenUrl !== undefined + || Object.prototype.hasOwnProperty.call(runtimeEnv, FACTORY_CLOUD_ACCESS_TOKEN_URL_ENV) + const hostedTokenProvider = !config.cloudAccessTokenProvider && hasHostedAccessTokenConfig ? createHostedCloudAccessTokenProvider({ - url: hostedAccessTokenUrl, + url: hostedAccessTokenUrl?.trim() ?? '', fetchImpl: config.cloudAccessTokenFetch ?? fetch, timeoutMs: config.cloudAccessTokenTimeoutMs ?? DEFAULT_HOSTED_ACCESS_TOKEN_TIMEOUT_MS, }) : undefined const directTokenProvider = config.cloudAccessTokenProvider ?? hostedTokenProvider - const sharedSession = directTokenProvider ? undefined : createSharedCloudSessionResolver(config) - const initialSession = sharedSession ? await sharedSession.resolve() : undefined + let initialSession: CloudSession | undefined + let tokenProvider: () => Promise + if (directTokenProvider) { + tokenProvider = directTokenProvider + } else { + const sharedSession = createSharedCloudSessionResolver(config) + initialSession = await sharedSession.resolve() + tokenProvider = sharedSession.getAccessToken + } const cloudApiUrl = config.cloudApiUrl ?? initialSession?.auth.apiUrl ?? (hostedTokenProvider ? (runtimeEnv.CLOUD_API_URL?.trim() || defaultApiUrl()) : undefined) if (!cloudApiUrl) { throw new Error('Relayfile hosted access requires cloudApiUrl with cloudAccessTokenProvider') } - const tokenProvider = directTokenProvider ?? sharedSession?.getAccessToken - if (!tokenProvider) { - throw new Error('Relayfile hosted access token provider is unavailable') - } const setup = (config.relayfileSetupFactory ?? createDefaultRelayfileSetup)({ cloudApiUrl, tokenProvider, @@ -1017,6 +1022,11 @@ const createHostedCloudAccessTokenProvider = (options: { signal: controller.signal, }) if (!response.ok) { + try { + await response.body?.cancel() + } catch { + // Preserve the HTTP failure if undici has already closed the body. + } throw new Error(`hosted Cloud access-token provider returned HTTP ${String(response.status)}`) } const payload = await response.json() as unknown From 2908e4daf5dd7d4d1ce05a3a941e2e63d39c3683 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Mon, 17 Aug 2026 13:52:46 +0200 Subject: [PATCH 3/4] fix: bypass local workspace auth in hosted mode --- src/mount/relayfile-cloud-mount-client.test.ts | 12 ++++++++++++ src/mount/relayfile-cloud-mount-client.ts | 9 +++++++++ 2 files changed, 21 insertions(+) diff --git a/src/mount/relayfile-cloud-mount-client.test.ts b/src/mount/relayfile-cloud-mount-client.test.ts index f17cbf8..69dd29b 100644 --- a/src/mount/relayfile-cloud-mount-client.test.ts +++ b/src/mount/relayfile-cloud-mount-client.test.ts @@ -16,6 +16,7 @@ import { FACTORY_RELAYFILE_SCOPES, RelayfileCloudMountClient, relayfileWorkspaceTokenProvider, + resolveFactoryWorkspace, type CloudSessionProvider, type RelayfileSetupFactory, type RelayfileCloudMountClientConfig, @@ -1112,6 +1113,17 @@ describe('RelayfileCloudMountClient', () => { expect(cloudSessionProvider).not.toHaveBeenCalled() }) + it('does not resolve a local Cloud workspace when hosted credentials are configured', async () => { + const activeWorkspaceResolver = vi.fn(async () => { + throw new Error('local Cloud workspace resolution must not run') + }) + + await expect(resolveFactoryWorkspace(activeWorkspaceResolver, { + [FACTORY_CLOUD_ACCESS_TOKEN_URL_ENV]: 'http://factory-auth.do/v1/access', + })).resolves.toEqual({ workspaceId: 'rw_7ccfea89' }) + expect(activeWorkspaceResolver).not.toHaveBeenCalled() + }) + it('cancels a failed hosted credential response body without reading it', async () => { const response = new Response('sensitive failure details', { status: 503 }) const cancel = vi.spyOn(response.body!, 'cancel') diff --git a/src/mount/relayfile-cloud-mount-client.ts b/src/mount/relayfile-cloud-mount-client.ts index 94eaf77..2ca1b0c 100644 --- a/src/mount/relayfile-cloud-mount-client.ts +++ b/src/mount/relayfile-cloud-mount-client.ts @@ -110,7 +110,16 @@ export interface ResolvedFactoryWorkspace { */ export async function resolveFactoryWorkspace( resolver: ActiveWorkspaceResolver = resolveActiveWorkspace, + env: NodeJS.ProcessEnv = process.env, ): Promise { + // Hosted Factory receives a workspace-bound relay_pa credential from its + // private endpoint. Never consult the laptop-oriented Cloud session path in + // that mode; doing so can invoke AGENT_RELAY_BIN before fromConfig() gets the + // chance to use the hosted provider. fromConfig() remains responsible for + // validating the configured endpoint URL and failing closed when malformed. + if (Object.prototype.hasOwnProperty.call(env, FACTORY_CLOUD_ACCESS_TOKEN_URL_ENV)) { + return { workspaceId: DEFAULT_WORKSPACE_ID } + } try { const descriptor = await resolver({ interactive: false }) if (descriptor.relayfileWorkspaceId) { From d409a2fb7a620c96ddd13a070ce412fbfa290dda Mon Sep 17 00:00:00 2001 From: Khaliq Date: Mon, 17 Aug 2026 14:03:37 +0200 Subject: [PATCH 4/4] fix: validate injected hosted path tokens --- .../relayfile-cloud-mount-client.test.ts | 50 ++++++++++++++++++- src/mount/relayfile-cloud-mount-client.ts | 15 +++++- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/mount/relayfile-cloud-mount-client.test.ts b/src/mount/relayfile-cloud-mount-client.test.ts index 69dd29b..0747eb4 100644 --- a/src/mount/relayfile-cloud-mount-client.test.ts +++ b/src/mount/relayfile-cloud-mount-client.test.ts @@ -1038,7 +1038,7 @@ describe('RelayfileCloudMountClient', () => { expect(cloudSessionProvider).not.toHaveBeenCalled() expect(relayfileSetupFactory).toHaveBeenCalledWith({ cloudApiUrl: 'https://cloud.example', - tokenProvider: cloudAccessTokenProvider, + tokenProvider: expect.any(Function), }) expect(setup.joinWorkspace).toHaveBeenCalledWith('rw_test', { agentName: 'agent-relay-factory', @@ -1047,6 +1047,54 @@ describe('RelayfileCloudMountClient', () => { await expect(capturedTokenProvider?.()).resolves.toBe('relay_pa_hosted_access') }) + it('rejects a non-path token from an injected hosted provider before workspace join', async () => { + const joinWorkspace = vi.fn() + const relayfileSetupFactory: RelayfileSetupFactory = ({ tokenProvider }) => ({ + joinWorkspace: async () => { + await tokenProvider() + return await joinWorkspace() + }, + }) + + await expect(RelayfileCloudMountClient.fromConfig({ + cloudApiUrl: 'https://cloud.example', + cloudAccessTokenProvider: async () => 'relay_ws_overbroad', + relayfileSetupFactory, + })).rejects.toThrow('invalid token class') + expect(joinWorkspace).not.toHaveBeenCalled() + }) + + it('uses an explicit hosted endpoint without resolving local Cloud workspace state', async () => { + const fake = new FakeRelayFileClient() + const cloudSessionProvider = vi.fn(async () => { + throw new Error('local Cloud login must not be consulted') + }) + const joinWorkspace = vi.fn(async () => ({ + client: () => fake, + getToken: async () => 'delegated-relayfile-token', + info: { relayfileUrl: 'https://relayfile.example' }, + })) + const relayfileSetupFactory: RelayfileSetupFactory = ({ tokenProvider }) => ({ + joinWorkspace: async (workspaceId, options) => { + await tokenProvider() + return await joinWorkspace(workspaceId, options) + }, + }) + + await RelayfileCloudMountClient.fromConfig({ + cloudAccessTokenUrl: 'http://factory-auth.do/v1/access', + cloudApiUrl: 'https://cloud.example', + cloudSessionProvider, + cloudAccessTokenFetch: vi.fn(async () => Response.json({ accessToken: 'relay_pa_hosted_access' })) as unknown as typeof fetch, + relayfileSetupFactory, + }) + + expect(cloudSessionProvider).not.toHaveBeenCalled() + expect(joinWorkspace).toHaveBeenCalledWith('rw_7ccfea89', expect.objectContaining({ + agentName: 'agent-relay-factory', + })) + }) + it('fails loudly when a hosted token provider has no Cloud API URL', async () => { await expect(RelayfileCloudMountClient.fromConfig({ workspaceId: 'rw_test', diff --git a/src/mount/relayfile-cloud-mount-client.ts b/src/mount/relayfile-cloud-mount-client.ts index 2ca1b0c..83c3e55 100644 --- a/src/mount/relayfile-cloud-mount-client.ts +++ b/src/mount/relayfile-cloud-mount-client.ts @@ -397,7 +397,10 @@ export class RelayfileCloudMountClient implements MountClient { timeoutMs: config.cloudAccessTokenTimeoutMs ?? DEFAULT_HOSTED_ACCESS_TOKEN_TIMEOUT_MS, }) : undefined - const directTokenProvider = config.cloudAccessTokenProvider ?? hostedTokenProvider + const unvalidatedDirectTokenProvider = config.cloudAccessTokenProvider ?? hostedTokenProvider + const directTokenProvider = unvalidatedDirectTokenProvider + ? createValidatedHostedAccessTokenProvider(unvalidatedDirectTokenProvider) + : undefined let initialSession: CloudSession | undefined let tokenProvider: () => Promise if (directTokenProvider) { @@ -1002,6 +1005,16 @@ const createDefaultRelayfileSetup: RelayfileSetupFactory = ({ cloudApiUrl, token accessToken: tokenProvider, }) as unknown as RelayfileSetupLike +const createValidatedHostedAccessTokenProvider = ( + provider: () => Promise, +): (() => Promise) => async () => { + const accessToken = (await provider()).trim() + if (!accessToken.startsWith('relay_pa_')) { + throw new Error('hosted Cloud access-token provider returned an invalid token class') + } + return accessToken +} + const createHostedCloudAccessTokenProvider = (options: { url: string fetchImpl: typeof fetch