diff --git a/src/mount/relayfile-cloud-mount-client.test.ts b/src/mount/relayfile-cloud-mount-client.test.ts index 4dcfda8..0747eb4 100644 --- a/src/mount/relayfile-cloud-mount-client.test.ts +++ b/src/mount/relayfile-cloud-mount-client.test.ts @@ -12,9 +12,11 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { + FACTORY_CLOUD_ACCESS_TOKEN_URL_ENV, FACTORY_RELAYFILE_SCOPES, RelayfileCloudMountClient, relayfileWorkspaceTokenProvider, + resolveFactoryWorkspace, type CloudSessionProvider, type RelayfileSetupFactory, type RelayfileCloudMountClientConfig, @@ -1005,6 +1007,214 @@ 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: expect.any(Function), + }) + expect(setup.joinWorkspace).toHaveBeenCalledWith('rw_test', { + agentName: 'agent-relay-factory', + scopes: [...FACTORY_RELAYFILE_SCOPES], + }) + 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', + 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 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('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') + 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 }) => ({ + 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..83c3e55 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/**', @@ -107,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) { @@ -190,6 +202,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 +385,40 @@ 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] + const hasHostedAccessTokenConfig = config.cloudAccessTokenUrl !== undefined + || Object.prototype.hasOwnProperty.call(runtimeEnv, FACTORY_CLOUD_ACCESS_TOKEN_URL_ENV) + const hostedTokenProvider = !config.cloudAccessTokenProvider && hasHostedAccessTokenConfig + ? createHostedCloudAccessTokenProvider({ + url: hostedAccessTokenUrl?.trim() ?? '', + fetchImpl: config.cloudAccessTokenFetch ?? fetch, + timeoutMs: config.cloudAccessTokenTimeoutMs ?? DEFAULT_HOSTED_ACCESS_TOKEN_TIMEOUT_MS, + }) + : undefined + const unvalidatedDirectTokenProvider = config.cloudAccessTokenProvider ?? hostedTokenProvider + const directTokenProvider = unvalidatedDirectTokenProvider + ? createValidatedHostedAccessTokenProvider(unvalidatedDirectTokenProvider) + : 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 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 +1005,72 @@ 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 + 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) { + 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 + 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